diff --git a/.clusterfuzzlite/build.sh b/.clusterfuzzlite/build.sh index 76e1986c..111f4700 100755 --- a/.clusterfuzzlite/build.sh +++ b/.clusterfuzzlite/build.sh @@ -21,3 +21,7 @@ PYI_ARGS=(--collect-submodules=email) for target in "$SRC"/integrations/.clusterfuzzlite/fuzz_*.py; do compile_python_fuzzer "$target" "${PYI_ARGS[@]}" done + +# Build the imported suites from the same checkout. Each keeps its reviewed lock. +bash "$SRC/integrations/examples/.clusterfuzzlite/build.sh" +bash "$SRC/integrations/demos/.clusterfuzzlite/build.sh" diff --git a/.gitattributes b/.gitattributes index 2e4a5028..9e9f2cb4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,7 @@ integrations/aeoess-aps/fixtures/action-receipt/*.json text eol=lf # Registry bytes; the tests check them against the digests Docker Hub serves. integrations/docker-sandbox-kit/fixtures/*.json -text + +# Imported JSON includes signed evidence and captured audit records. +examples/**/*.json -text +demos/**/*.json -text diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ea28ab33..5a9b6b21 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1,7 @@ * @imran-siddique @carloshvp @agentrust-io/maintainers + +# Reviewed first-party scenarios and demonstrations. +/examples/ @imran-siddique @carloshvp @agentrust-io/maintainers +/demos/ @imran-siddique @pforest @podcastinator @Qiang-Xu @agentrust-io/maintainers +/.github/workflows/examples-* @imran-siddique @carloshvp @agentrust-io/maintainers +/.github/workflows/demos-* @imran-siddique @pforest @podcastinator @Qiang-Xu @agentrust-io/maintainers diff --git a/.github/dependabot.yml b/.github/dependabot.yml index aa6389c2..5332a5e2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -34,3 +34,16 @@ updates: interval: "weekly" day: "monday" open-pull-requests-limit: 10 + + - package-ecosystem: "pip" + directories: + - "/examples" + - "/demos" + schedule: + interval: "weekly" + day: "monday" + open-pull-requests-limit: 5 + ignore: + - dependency-name: "*" + update-types: + - "version-update:semver-major" diff --git a/.github/workflows/cflite_pr.yml b/.github/workflows/cflite_pr.yml index 5a215b11..ae3e4c26 100644 --- a/.github/workflows/cflite_pr.yml +++ b/.github/workflows/cflite_pr.yml @@ -3,6 +3,8 @@ name: ClusterFuzzLite PR on: pull_request: paths: + - "examples/**" + - "demos/**" - 'packages/agentrust-capture-core/src/**' - 'packages/agentrust-trace-adapters/src/**' - '.clusterfuzzlite/**' diff --git a/.github/workflows/demos-ci.yml b/.github/workflows/demos-ci.yml new file mode 100644 index 00000000..4e1ca799 --- /dev/null +++ b/.github/workflows/demos-ci.yml @@ -0,0 +1,81 @@ +name: Demos + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + CMCP_DEV_MODE: "1" + CMCP_BEARER_TOKEN: demo-token + +permissions: + contents: read + +defaults: + run: + working-directory: demos + +jobs: + demos: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up Python 3.11 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + + # requirements.txt holds the floors for cmcp-runtime (demos 1-5) and + # weight-custody-manifest (demos 6-9); installing cmcp-runtime alone once + # left the WCM demos unrunnable. requirements/ci.txt is that file plus + # pytest and httpx, resolved and hash-locked, so CI installs exactly what + # was reviewed. + - name: Install demo dependencies + run: pip install --require-hashes -r requirements/ci.txt + + - name: Validate shared financial-services example + run: | + test -f ../examples/financial-services/catalog.json + test -f ../examples/financial-services/policy/allow.cedar + python -m py_compile web-console/run.py web-console/webserver.py web-console/policy_variants.py + + # Static checks first: they need no ports and fail in under a second. + # test_catalog_definition_drift catches a catalog that no longer matches + # its upstream server, which the gateway treats as a rug pull and denies. + - name: Tests + run: python -m pytest tests/ -q + + # Run in order: demo-01 produces workspace/trace-claim.json, which + # demo-02 and demo-03 consume. + - name: Demo 1 - cMCP in action + run: python demo-01-cmcp-in-action/run.py + + - name: Demo 2 - policy swap + run: python demo-02-policy-swap/run.py + + - name: Demo 3 - offline TRACE verification + run: python demo-03-offline-trace/run.py + + - name: Demo 4 - context-aware enforcement + run: python demo-04-context-enforcement/run.py + + - name: Demo 5 - attribute-based enforcement (BAA coverage) + run: python demo-05-compliance-domain/run.py + + # Demos 6-9 are offline: no gateway, no ports, no MCP server. + - name: Demos 6-9 - weight custody + run: | + for d in demo-06-weight-custody demo-07-closed-weight demo-08-derivative-lineage demo-09-sovereign-threshold; do + echo "== $d ==" + python "$d/run.py" + done + + - name: Demo 10 - governed model calls + run: python demo-10-model-gateway/run.py diff --git a/.github/workflows/examples-ci.yml b/.github/workflows/examples-ci.yml new file mode 100644 index 00000000..ed00f54d --- /dev/null +++ b/.github/workflows/examples-ci.yml @@ -0,0 +1,227 @@ +name: Examples + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + +permissions: + contents: read + +defaults: + run: + working-directory: examples + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Validate JSON files + run: | + find . -name "*.json" -not -path "./.git/*" | while read -r f; do + python3 -m json.tool "$f" > /dev/null && echo "OK: $f" || exit 1 + done + - name: Validate YAML files + run: | + pip install -q --require-hashes -r requirements/yaml-lint.txt + find . -name "*.yaml" -o -name "*.yml" | grep -v ".git" | while read -r f; do + python3 -c "import yaml, sys; yaml.safe_load(open(sys.argv[1]))" "$f" && echo "OK: $f" || exit 1 + done + + financial-services: + runs-on: ubuntu-latest + defaults: + run: + working-directory: examples/financial-services + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python 3.11 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + + - name: Run credit engine tests + run: python -m unittest discover -s tests -v + + agentic-commerce-accountability: + runs-on: ubuntu-latest + defaults: + run: + working-directory: examples/agentic-commerce-accountability + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python 3.11 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + + - name: Run accountability tests + run: python -m unittest discover -s tests -v + + - name: Regenerate and compare committed fixtures + run: | + python generate_fixtures.py + git diff --exit-code -- fixtures + + released-ucp-accountability: + runs-on: ubuntu-24.04 + timeout-minutes: 20 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Build the locked released-UCP verification image + run: >- + docker build --no-cache --tag released-ucp-accountability:ci + agentic-commerce-accountability/released_ucp + + - name: Run the demo and checks without network or elevated privileges + run: >- + docker run --rm --network none --read-only --cap-drop ALL + --security-opt no-new-privileges --pids-limit 128 + --memory 1g --cpus 2 --tmpfs /tmp:rw,nosuid,nodev,size=256m + released-ucp-accountability:ci + + industrial-embodied-ai: + runs-on: ubuntu-latest + defaults: + run: + working-directory: examples/industrial-embodied-ai + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python 3.11 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + cache: "pip" + cache-dependency-path: examples/industrial-embodied-ai/requirements.txt + + - name: Install controller test dependencies + run: python -m pip install --require-hashes -r ../requirements/controller-tests.txt + + - name: Run controller tests + run: python -m unittest discover -s tests -v + + - name: Install artifact validation dependencies + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + run: python -m pip install -r requirements.txt + + - name: Verify committed evidence artifacts + if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository + run: python validate_artifacts.py + + healthcare: + runs-on: ubuntu-latest + defaults: + run: + working-directory: examples/healthcare + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python 3.11 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + + - name: Run clinical engine tests + run: python -m unittest discover -s tests -v + + embodied-action-receipts: + runs-on: ubuntu-latest + defaults: + run: + working-directory: examples/embodied-action-receipts + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python 3.11 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + cache: "pip" + cache-dependency-path: examples/requirements/receipts-tests.txt + + - name: Install dependencies + # The job cwd is embodied-action-receipts (defaults above), so the + # lock is one level up. + run: python -m pip install --require-hashes -r ../requirements/receipts-tests.txt + + - name: Verify embodied-action receipt fixtures + run: python -m unittest discover -s tests -v + + multi-tenant-saas: + runs-on: ubuntu-latest + defaults: + run: + working-directory: examples/multi-tenant-saas + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python 3.11 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + + - name: Run people directory tests + run: python -m unittest discover -s tests -v + + ca2a-delegation: + runs-on: ubuntu-latest + defaults: + run: + working-directory: examples/ca2a-delegation + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python 3.11 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + + - name: Install cA2A runtime (pre-release) + run: python -m pip install --pre ca2a-runtime + + - name: Run delegation tests + run: python -m unittest discover -s tests -v + + - name: Run the delegation demo + run: python delegation_agent.py + + weight-custody-manifest: + runs-on: ubuntu-latest + defaults: + run: + working-directory: examples/weight-custody-manifest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python 3.11 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + + - name: Install the WCM SDK from PyPI + run: python -m pip install -r requirements.txt + + # One runner, discovered rather than listed, so a new demo is covered the + # day it lands. The list lived in this file and in nobody else's reach, + # which is why the SDK repository could not run these before cutting a + # release. It now calls the same script; see run_demos.py. + - name: Install the model-signing extra the provenance demo needs + # The runner is pinned; the SDK these demos exercise stays resolved + # from PyPI, which is what makes them a check on the release. + run: | + python -m pip install --require-hashes -r ../requirements/wcm-harness.txt + python -m pip install "weight-custody-manifest[model-signing]>=0.27.0" + + - name: Run every offline demo and the unit tests + run: python run_demos.py diff --git a/.github/workflows/examples-smoke.yml b/.github/workflows/examples-smoke.yml new file mode 100644 index 00000000..2fe74a3a --- /dev/null +++ b/.github/workflows/examples-smoke.yml @@ -0,0 +1,92 @@ +name: Examples smoke + +on: + push: + branches: [main] + pull_request: + branches: [main] + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" + # cmcp CLI prints unicode glyphs; force UTF-8 so it is safe on any runner locale. + PYTHONUTF8: "1" + +permissions: + contents: read + +defaults: + run: + working-directory: examples + +jobs: + smoke: + name: smoke (${{ matrix.name }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - name: startup-tpm + dir: startup-tpm + config: cmcp-config.yaml + agent: agent/echo_agent.py + args: "" + - name: financial-services + dir: financial-services + config: cmcp-config.yaml + agent: agent/credit_risk_agent.py + args: "" + - name: healthcare + dir: healthcare + config: cmcp-config.yaml + agent: agent/clinical_decision_agent.py + args: "" + - name: multi-tenant-metzler-eu + dir: multi-tenant-saas + config: cmcp-config-metzler-eu.yaml + agent: agent/saas_agent.py + args: "--tenant metzler-eu" + - name: multi-tenant-summit-us + dir: multi-tenant-saas + config: cmcp-config-summit-us.yaml + agent: agent/saas_agent.py + args: "--tenant summit-us" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Python 3.11 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.11" + + - name: Install dependencies + # cmcp-runtime is the subject of these smoke tests and stays resolved + # from PyPI; the HTTP client driving them is pinned. + run: | + pip install --require-hashes -r requirements/smoke-harness.txt + pip install cmcp-runtime + + - name: Start mock MCP server + run: python ${{ matrix.dir }}/server/mock_mcp_server.py & + + - name: Start cMCP gateway + working-directory: examples/${{ matrix.dir }} + env: + CMCP_DEV_MODE: "1" + run: cmcp start --config ${{ matrix.config }} & + + - name: Wait for gateway + run: | + for i in $(seq 1 20); do + if curl -sf http://localhost:8443/health > /dev/null 2>&1; then + echo "Gateway ready" + exit 0 + fi + echo "Waiting ($i)..." + sleep 2 + done + echo "Gateway did not become ready in time" >&2 + exit 1 + + - name: Run agent + run: python ${{ matrix.dir }}/${{ matrix.agent }} ${{ matrix.args }} diff --git a/.github/workflows/links.yml b/.github/workflows/links.yml index 8e2aeecd..dad3e3b2 100644 --- a/.github/workflows/links.yml +++ b/.github/workflows/links.yml @@ -21,6 +21,7 @@ jobs: persist-credentials: false - uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0 with: + # These three ISO pages were verified manually; ISO rejects the CI crawler with 403. args: >- --no-progress --exclude-path .nox @@ -29,6 +30,7 @@ jobs: --exclude 'https://dcbadge\.limes\.pink/.*' --exclude 'https://www\.npmjs\.com/.*' --exclude 'https://agent-passport\.org/.*' + --exclude '^https://www\.iso\.org/standard/(73933|73934|73481)\.html$' --max-retries 3 '**/*.md' '**/integration.yaml' diff --git a/.gitignore b/.gitignore index 9c882b75..1ccc4c7b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,7 @@ __pycache__/ *.egg-info/ + +.nox/ +.pytest_cache/ +.ruff_cache/ +.venv/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ee46c96..86b8dcd6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,16 @@ -# Contributing an integration +# Contributing One directory per integration, one PR per change. Self-serve: you do not need an invitation. -## Layout +## Examples and demos + +`examples/` contains first-party scenarios and invited partner examples. Maintainers review every line and verify each documented claim against a run before merge. See [the example contribution rules](examples/CONTRIBUTING.md). Vendor integrations belong under `integrations/` and retain the tier rules below. + +`demos/` contains short demonstrations and the browser console. Contributions must include reproducible steps and retain the software-only and hardware-evidence limitations. Maintainers review changes and CI runs the demo suite. See [the demo contribution rules](demos/CONTRIBUTING.md). + +An integration listing does not imply that its code has received the example or demo review. + +## Integration layout ``` integrations/-/ diff --git a/README.md b/README.md index f6956ae4..9b74c13c 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) [![Discord](https://dcbadge.limes.pink/api/server/grgzFEHgkj?style=flat)](https://discord.gg/grgzFEHgkj) -# agentrust-io Integrations +# AgenTrust integrations, examples, and demos Community updates and contributor highlights: [AgenTrust on LinkedIn](https://www.linkedin.com/company/agentrust-io/). -The ecosystem front door for cMCP, TRACE, and Agent Manifest. Vendors and community projects integrate here, on their own terms, under published rules - while the core repos stay first-party. +One place to try and integrate cMCP, cA2A, TRACE, Agent Manifest, and WCM. Vendors and community projects integrate here, on their own terms, under published rules - while the core repos stay first-party. Project support is recognized in [SPONSORS.md](SPONSORS.md). Sponsorship is separate from marketplace listing, verification tier, maintainership, and @@ -13,12 +13,21 @@ project governance. ## Where things live -| Repo | What belongs there | Who contributes | +| Location | What belongs there | Who contributes | |---|---|---| | [cmcp](https://github.com/agentrust-io/cmcp), [agent-manifest](https://github.com/agentrust-io/agent-manifest), [trace-spec](https://github.com/agentrust-io/trace-spec), [trace-tests](https://github.com/agentrust-io/trace-tests) | The standard and reference implementation. Bug fixes and spec feedback welcome; no vendor product code. | Maintainers; community fixes | -| [examples](https://github.com/agentrust-io/examples) | First-party, end-to-end runnable examples, plus flagship partner examples by invitation. Every line is reviewed and every claim verified before merge. | Maintainers; invited partners | -| **this repo** | Your product's integration with cMCP, TRACE, or Agent Manifest: adapters, exporters, dashboards, policy packs, verifiers. Vendor-maintained. | Anyone, self-serve | +| [examples/](examples/) | First-party, end-to-end runnable examples, plus flagship partner examples by invitation. Every line is reviewed and every claim verified before merge. | Maintainers; invited partners | +| [integrations/](integrations/) | Your product's integration with cMCP, TRACE, or Agent Manifest: adapters, exporters, dashboards, policy packs, verifiers. Vendor-maintained. | Anyone, self-serve | | [awesome-ai-governance](https://github.com/agentrust-io/awesome-ai-governance) | Neutral listings of notable agent-governance tools, including ones that do not integrate with this stack. | Anyone meeting the listing criteria | +| [demos/](demos/) | Short runnable demonstrations and the browser console, using the examples in this repository. | Maintainers; reviewed contributions | + +## Try the stack + +- [Examples](examples/README.md): complete scenarios and offline evidence verification. +- [Demos](demos/README.md): short demonstrations and the browser console. +- [Integrations](#index): adapters and vendor-maintained connections. + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the review rules for each directory. Existing adapter and package paths remain supported. Examples retain [Apache-2.0](examples/LICENSE); demos retain [MIT](demos/LICENSE). ## Tiers diff --git a/demos/.clusterfuzzlite/Dockerfile b/demos/.clusterfuzzlite/Dockerfile new file mode 100644 index 00000000..0966c55e --- /dev/null +++ b/demos/.clusterfuzzlite/Dockerfile @@ -0,0 +1,5 @@ +FROM gcr.io/oss-fuzz-base/base-builder-python + +COPY . $SRC/integrations +COPY demos/.clusterfuzzlite/build.sh $SRC/build.sh +WORKDIR $SRC/integrations diff --git a/demos/.clusterfuzzlite/README.md b/demos/.clusterfuzzlite/README.md new file mode 100644 index 00000000..ab9abf4f --- /dev/null +++ b/demos/.clusterfuzzlite/README.md @@ -0,0 +1,52 @@ +# Fuzzing + +Coverage-guided fuzzing via [ClusterFuzzLite](https://google.github.io/clusterfuzzlite/), +running Atheris against the parts of this repo that take input from a network +peer or a browser. + +The demos are mostly scripts that drive cMCP Runtime and the WCM SDK, and those +packages are fuzzed in their own repos. What is ours here is three small HTTP +servers, so that is what these targets cover. + +| Target | Surface | Property | +| --- | --- | --- | +| `fuzz_console_requests.py` | `web-console/webserver.py` POST handling: Host, Origin, Content-Type, Content-Length and the JSON body | One response per request, nothing escapes the handler, and the example agent only runs when every gate passed | +| `fuzz_demo_servers.py` | `server/server.py` JSON-RPC tool calls, and the demo 10 `endpoint.py` chat request | Always a well-formed response; no file is touched outside the workspace; only a data class the Cedar bundle names is forwarded; the forwarded prompt matches none of the endpoint's own identifier patterns | + +The agent run, the policy-bundle hashing and the gateway hop are stubbed. They +start subprocesses or need ports, and the properties above have to hold +whatever they would have returned. + +## Standing when added + +Neither target has run under libFuzzer yet; the first CI run is the first real +one. Before that, each target's `TestOneInput` was driven in plain Python over +3,000 random and dictionary-built inputs plus a seeded grid (every Host, Origin, +Content-Type, path and Content-Length combination for the console; traversal +paths and overlapping identifier shapes for the servers), with no failures. Both +were also bundled with PyInstaller using the same `--paths` flags as `build.sh` +and run from outside the source tree. + +Each property was checked against a deliberately broken build: with the console's +Host gate removed the first target fails on a rebound Host, and with the file +server's path containment removed the second fails on `../`. + +The request-level bugs these targets guard (cross-origin and rebinding requests +reaching `/api/run` and `write_file`, unhandled exceptions on malformed bodies, +an unknown data class going out of region) were found by review and are pinned +by `tests/test_request_hardening.py`. + +## Running locally + +``` +git clone https://github.com/google/clusterfuzzlite --depth 1 /tmp/clusterfuzzlite +python /tmp/clusterfuzzlite/infra/helper.py build_image --external $PWD +python /tmp/clusterfuzzlite/infra/helper.py build_fuzzers --external --sanitizer address $PWD +python /tmp/clusterfuzzlite/infra/helper.py run_fuzzer --external $PWD fuzz_demo_servers +``` + +## In CI + +`cflite_pr.yml` fuzzes code a pull request touched for five minutes. +`cflite_batch.yml` runs both targets for 30 minutes, nightly. Both are read-only +and report through the job log and the crash artifact. diff --git a/demos/.clusterfuzzlite/build.sh b/demos/.clusterfuzzlite/build.sh new file mode 100755 index 00000000..83c60784 --- /dev/null +++ b/demos/.clusterfuzzlite/build.sh @@ -0,0 +1,26 @@ +#!/bin/bash -eu +# Build the fuzz targets for ClusterFuzzLite. +# +# The targets import the demo servers straight from the source tree, so the only +# install is their runtime (starlette), from a hashed lock. Atheris ships with +# the base image. + +cd "$SRC/integrations/demos" +pip3 install --no-cache-dir --require-hashes -r requirements/fuzz.txt + +# compile_python_fuzzer bundles each target with PyInstaller, which follows +# static imports only and does not see the sys.path the targets set up at run +# time. --paths hands it the three directories the demo modules live in. +# --collect-submodules=email is the house default: several stacks reach +# email.mime lazily and a bundled target then dies with ModuleNotFoundError, +# which libFuzzer reports as a crash in the target. +PYI_ARGS=( + --paths="$SRC/integrations/demos/web-console" + --paths="$SRC/integrations/demos/server" + --paths="$SRC/integrations/demos/demo-10-model-gateway" + --collect-submodules=email +) + +for target in "$SRC"/integrations/demos/.clusterfuzzlite/fuzz_*.py; do + compile_python_fuzzer "$target" "${PYI_ARGS[@]}" +done diff --git a/demos/.clusterfuzzlite/fuzz_console_requests.py b/demos/.clusterfuzzlite/fuzz_console_requests.py new file mode 100644 index 00000000..9e259228 --- /dev/null +++ b/demos/.clusterfuzzlite/fuzz_console_requests.py @@ -0,0 +1,120 @@ +#!/usr/bin/python3 +"""Fuzz the web console's request gate and JSON body handling. + +web-console/webserver.py is the one thing in this repo a browser talks to, and +POST /api/run spawns the example agent and can start a second gateway. The +properties asserted here: + + 1. every request gets exactly one response and nothing escapes the handler + (an exception there drops the connection instead of answering it); + 2. the agent only runs for a request that carried a loopback Host, no + foreign Origin, Content-Type application/json, a JSON object body, and a + known scenario and variant. + +The agent run and the policy-bundle hashing are stubbed: they are subprocess +and cmcp-runtime territory, not request parsing, and the gate must hold +regardless of what they would do. +""" +import http.client +import io +import json +import os +import sys +import types + +import atheris + +HERE = os.path.dirname(os.path.abspath(__file__)) +CONSOLE = os.path.join(HERE, "..", "web-console") +if os.path.isdir(os.path.join(HERE, "web-console")): # layout inside the fuzz build + CONSOLE = os.path.join(HERE, "web-console") +sys.path.insert(0, CONSOLE) + +# policy_variants pulls in cmcp-runtime (Cedar bindings, crypto) only to hash +# bundles and start a gateway. Neither is on the request-parsing path. +_pv = types.ModuleType("policy_variants") +_pv.TamperedGateway = lambda: types.SimpleNamespace( + ensure=lambda env: "http://localhost:8444", applied=[], stop=lambda: None) +_pv.EDITS = [] +sys.modules["policy_variants"] = _pv + +with atheris.instrument_imports(): + import webserver + +PORT = 8000 +RUNS = [] + + +def _fake_run_agent(scenario, gateway=None): + RUNS.append(scenario) + return {"scenario": scenario, "steps": [], "claim": None, "error": None} + + +webserver._run_agent = _fake_run_agent +webserver._policy_hashes = lambda: {"approved": "sha256:a", "tampered": "sha256:t"} +webserver._verify = lambda pinned=None: ({"raw": "", "checks": [], "result": None}, 200) + +HOSTS = [f"localhost:{PORT}", f"127.0.0.1:{PORT}", f"[::1]:{PORT}", + f"attacker.example:{PORT}", "localhost", ""] +ORIGINS = [None, f"http://localhost:{PORT}", "https://attacker.example", "null"] +CTYPES = ["application/json", "application/json; charset=utf-8", "text/plain", + "application/x-www-form-urlencoded", ""] +PATHS = ["/api/run", "/api/verify", "/api/other", "/"] +SCENARIOS = {s["id"] for s in webserver.SCENARIOS} + + +def _pick(options, byte): + return options[byte % len(options)] + + +def TestOneInput(data: bytes) -> None: + if len(data) < 5: + return + host, origin = _pick(HOSTS, data[0]), _pick(ORIGINS, data[1]) + ctype, path = _pick(CTYPES, data[2]), _pick(PATHS, data[3]) + raw_length = data[4] % 4 + body = data[5:] + + headers = http.client.HTTPMessage() + if host: + headers["Host"] = host + if origin is not None: + headers["Origin"] = origin + if ctype: + headers["Content-Type"] = ctype + # Mostly honest lengths, sometimes the lies a raw socket can tell. + headers["Content-Length"] = {0: str(len(body)), 1: "-5", 2: "x", 3: str(len(body))}[raw_length] + + handler = object.__new__(webserver.Handler) + handler.headers = headers + handler.path = path + handler.rfile = io.BytesIO(body) + handler.server = types.SimpleNamespace(server_address=("127.0.0.1", PORT)) + sent = [] + handler._send = lambda status, payload, ct="application/json": sent.append(status) + + RUNS.clear() + handler.do_POST() + + assert len(sent) == 1, f"expected one response, got {sent}" + if not RUNS: + return + # The agent ran: every gate must have been satisfied. + assert path == "/api/run" + assert host in HOSTS[:3], host + assert origin in (None, f"http://localhost:{PORT}"), origin + assert ctype.startswith("application/json"), ctype + payload = json.loads(body or b"{}") + assert isinstance(payload, dict) + assert payload.get("scenario", "clean") in SCENARIOS + assert payload.get("variant", "approved") in ("approved", "tampered") + assert sent == [200] + + +def main(): + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/demos/.clusterfuzzlite/fuzz_demo_servers.py b/demos/.clusterfuzzlite/fuzz_demo_servers.py new file mode 100644 index 00000000..0b3e893f --- /dev/null +++ b/demos/.clusterfuzzlite/fuzz_demo_servers.py @@ -0,0 +1,144 @@ +#!/usr/bin/python3 +"""Fuzz the two demo HTTP servers that take JSON from the network. + +server/server.py (the filesystem MCP server behind demos 1 to 5) turns a +JSON-RPC body into file reads and writes. Property: it always answers with a +JSON-RPC response, and nothing it does touches a path outside its workspace. + +demo-10-model-gateway/endpoint.py (the OpenAI-compatible endpoint) turns a chat +request into a governed tool call. Properties: it always answers with 200, 400 +or 403; a request is only forwarded with a data class the Cedar bundle knows; +and the prompt it forwards matches none of its own identifier patterns, so +redaction is not undone by the order the patterns run in. + +The gateway hop is stubbed, so this needs no ports and no cmcp-runtime. +""" +import asyncio +import json +import os +import sys +import tempfile + +import atheris + +HERE = os.path.dirname(os.path.abspath(__file__)) +for sub in ("server", "demo-10-model-gateway"): + for base in (os.path.join(HERE, ".."), HERE): + if os.path.isdir(os.path.join(base, sub)): + sys.path.insert(0, os.path.join(base, sub)) + break + +with atheris.instrument_imports(): + import endpoint + import server + +from starlette.requests import Request # noqa: E402 + +ROOT = tempfile.mkdtemp(prefix="demos-fuzz-") +WORKSPACE = os.path.join(ROOT, "workspace") +os.makedirs(WORKSPACE) +server.WORKSPACE = __import__("pathlib").Path(WORKSPACE) + +FORWARDED = [] + + +def _fake_post(url, payload, timeout=20): + FORWARDED.append(payload) + text = json.dumps({"content": "ok"}) + return {"result": {"content": [{"type": "text", "text": text}]}}, 200 + + +endpoint._post = _fake_post + +TOOLS = ["write_file", "read_file", "list_dir", "unknown"] +CLASSES = ["public", "pii", "confidential", "hipaa_phi", "Confidential", "", None] +MODELS = list(endpoint.MODELS) + ["unknown-model"] + + +def _request(path, body: bytes, headers: dict) -> Request: + scope = { + "type": "http", "method": "POST", "path": path, "raw_path": path.encode(), + "query_string": b"", "scheme": "http", "server": ("127.0.0.1", 9001), + "client": ("127.0.0.1", 50000), "root_path": "", + "headers": [(k.lower().encode("latin-1"), v.encode("latin-1")) + for k, v in headers.items()], + } + sent = False + + async def receive(): + nonlocal sent + if sent: + return {"type": "http.disconnect"} + sent = True + return {"type": "http.request", "body": body, "more_body": False} + + return Request(scope, receive) + + +def _text(data: bytes) -> str: + return data.decode("utf-8", errors="surrogateescape").encode( + "utf-8", errors="replace").decode("utf-8") + + +def _outside_workspace() -> set: + return {e for e in os.listdir(ROOT) if e != "workspace"} + + +def _fuzz_fs_server(sel: int, data: bytes) -> None: + if sel & 1: + body = data # raw bytes, whatever they are + else: + cut = len(data) // 2 + body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { + "name": TOOLS[sel % len(TOOLS)], + "arguments": {"path": _text(data[:cut]), "content": _text(data[cut:])}, + }}).encode() + before = _outside_workspace() + resp = asyncio.run(server.handle(_request( + "/mcp", body, {"host": "localhost:9001", "content-type": "application/json"}))) + assert resp.status_code in (200, 400, 403, 404, 415, 500), resp.status_code + out = json.loads(resp.body) + assert out.get("jsonrpc") == "2.0" and ("result" in out or "error" in out) + assert _outside_workspace() == before, "a write escaped the workspace" + + +def _fuzz_endpoint(sel: int, data: bytes) -> None: + headers = {"host": "127.0.0.1:8500", "content-type": "application/json"} + if sel & 1: + body = data + else: + cls = CLASSES[sel % len(CLASSES)] + if cls is not None: + headers["x-data-class"] = cls + body = json.dumps({"model": MODELS[(sel >> 3) % len(MODELS)], + "messages": [{"role": "user", "content": _text(data)}]}).encode() + FORWARDED.clear() + resp = asyncio.run(endpoint.chat_completions(_request("/v1/chat/completions", body, headers))) + assert resp.status_code in (200, 400, 403), resp.status_code + json.loads(resp.body) + for call in FORWARDED: + args = call["params"]["arguments"] + assert args["data_class"] in endpoint.DATA_CLASSES, args["data_class"] + for label, pattern in endpoint.REDACTIONS: + assert not pattern.search(args["prompt"]), f"{label} survived redaction" + if args["contains_identifiers"]: + assert args["redacted"], "identifiers seen but nothing redacted" + + +def TestOneInput(data: bytes) -> None: + if len(data) < 2: + return + sel, rest = data[1], data[2:] + if data[0] & 1: + _fuzz_fs_server(sel, rest) + else: + _fuzz_endpoint(sel, rest) + + +def main(): + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/demos/.gitattributes b/demos/.gitattributes new file mode 100644 index 00000000..394b1b09 --- /dev/null +++ b/demos/.gitattributes @@ -0,0 +1,7 @@ +* text=auto +*.sh text eol=lf +*.py text eol=lf +*.cedar text eol=lf +*.yaml text eol=lf +*.json text eol=lf +*.md text eol=lf diff --git a/demos/.gitignore b/demos/.gitignore new file mode 100644 index 00000000..e928fa2e --- /dev/null +++ b/demos/.gitignore @@ -0,0 +1,13 @@ +workspace/ +*.db +audit.db +__pycache__/ +*.pyc +.venv/ +*.egg-info/ +dist/ +*.log +audit.db-shm +audit.db-wal +audit-v2.db-shm +audit-v2.db-wal diff --git a/demos/CONTRIBUTING.md b/demos/CONTRIBUTING.md new file mode 100644 index 00000000..3a9e9462 --- /dev/null +++ b/demos/CONTRIBUTING.md @@ -0,0 +1,63 @@ +# Contributing + +Thanks for looking. This directory holds runnable demos for cMCP, TRACE and the +agent governance tooling. Everything here is meant to be executed rather than only read, +which shapes what a good contribution looks like. + +## Getting the code and running it + +``` +git clone https://github.com/agentrust-io/integrations +cd integrations/demos +pip install -r requirements.txt +python demo-01-cmcp-in-action/run.py +``` + +The README lists every demo and roughly how long each takes. CI runs all of them +on every pull request, so if a demo does not run from a clean checkout, that is a +bug worth reporting. + +## Reporting a problem or suggesting a demo + +Open an issue: . For a broken demo, +include the command you ran and what it printed. The output is the evidence. + +For a security issue, do not open an issue. See [SECURITY.md](SECURITY.md). + +## What an acceptable contribution looks like + +**A demo must assert its own outcome.** This is the one rule worth stating +plainly. A demo prints claims about what was enforced, denied, or verified, and a +reader believes them. So the script must check the thing it claims and fail +loudly when the check fails. Printing a heading and then printing a value that +contradicts it is the specific failure mode we have already shipped once: demo-06 +printed `gate released key: False` under a heading saying the opposite. A demo +that can print a success message while the underlying check failed is not +finished. + +Concretely, for a change to be merged: + +- The demo runs end to end from a clean checkout with the documented command. +- It exits non-zero when its own assertion fails, so CI catches it. +- Its output states what actually happened, including when that is a denial. +- Any new demo is added to the README with what it shows and how long it takes, + and to `../.github/workflows/demos-ci.yml` so it is run on every pull request. +- Tests under `tests/` cover any shared helper you add. + +## Style + +- Match the surrounding code. There is no separate style guide to learn. +- Commit messages follow [Conventional Commits](https://www.conventionalcommits.org), + e.g. `fix(demo-06): assert the gate result the heading claims`. +- Keep pull requests small and single-purpose. + +## Fixtures and keys + +Everything committed here is synthetic. Sample keys and fixtures exist to be +published and are not secrets. Never add a real credential, endpoint or customer +identifier, even in a comment. + +## Licence + +By contributing you agree that your contribution is licensed under the MIT +Licence in [LICENSE](LICENSE), the same terms as the rest of the repository. diff --git a/demos/LICENSE b/demos/LICENSE new file mode 100644 index 00000000..ecf1b704 --- /dev/null +++ b/demos/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Agentic AI Foundation contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/demos/README.md b/demos/README.md new file mode 100644 index 00000000..0455ac5f --- /dev/null +++ b/demos/README.md @@ -0,0 +1,315 @@ +# agentrust-io demos + +Community updates and contributor highlights: [AgenTrust on LinkedIn](https://www.linkedin.com/company/agentrust-io/). + +Runnable demos for [cMCP](https://github.com/agentrust-io/cmcp), [TRACE](https://github.com/agentrust-io/trace-spec), and [WCM](https://pypi.org/project/weight-custody-manifest/). Ten demos, ~12 minutes total. + +Project support is recognized in [SPONSORS.md](SPONSORS.md). Sponsorship is +separate from demo authorship, fixture identities, and project governance. + +--- + +Clone the combined repository and run commands from `integrations/demos`: + +```bash +git clone https://github.com/agentrust-io/integrations.git +cd integrations/demos +``` + +## Prerequisites + +``` +pip install -r requirements.txt +``` + +The requirements install cMCP for demos 1 through 5, Weight Custody Manifest for demos 6 through 9, and the OpenAI client used by demo 10. All demos use `CMCP_DEV_MODE=1` (software-only TEE, no hardware required). The local MCP server performs real filesystem operations on `./workspace/`. + +## Quick start: one command + +``` +python demo.py # run all demos, pausing before each (good for live talks) +python demo.py --no-pause # run straight through +python demo.py 2 # run only demo 2 +``` + +`demo.py` sets the token and dev mode for you and prints the detected `cmcp-runtime` version. If your active Python cannot find the `cmcp` command, it will use a local `.venv` if one exists (`python -m venv .venv` then install `cmcp-runtime` into it). To run the demos individually instead, use the per-demo commands below. + +Set a bearer token (the cMCP Runtime requires one): + +```bash +# bash +export CMCP_BEARER_TOKEN=demo-token +``` + +```powershell +# PowerShell +$env:CMCP_BEARER_TOKEN = "demo-token" +``` + +Each demo has a `run.py` (works on any OS) and a `run.sh` (bash only). Server and cMCP Runtime logs are written to `*.log` files in the demo directory, not the terminal. + +--- + +## Browser console + +A point-and-click version for showing on a screen instead of a terminal. It runs the real `financial-services` example (the shared EU corporate credit-risk scenario in `../examples/financial-services`) behind a small web UI. + +``` +pip install cmcp-runtime httpx +python web-console/run.py +``` + +Opens `http://localhost:8000`. Pick an obligor and run a six-step credit assessment: watch Cedar allow each step and block the write when the result breaches a control (CRR concentration, EBA delegated authority, IFRS 9, EU AML), each deny citing its regulation. Close the session into a signed TRACE record and verify it offline -- every result comes from the real gateway. See [`web-console/README.md`](web-console/README.md). + +--- + +## Demo 1 -- cMCP in action (~90 seconds) + +The agent calls three tools through the cMCP gateway. Cedar policy is enforced for every call. At session close, the gateway produces a signed TRACE claim carrying the policy bundle hash. + +On real Intel TDX hardware, the policy bundle hash flows into RTMR[2] at startup. Here it appears in `trace.policy.bundle_hash` in the claim. + +``` +python demo-01-cmcp-in-action/run.py +``` + +What you see: +- cMCP starts, measures the policy bundle hash +- `write_file`: Cedar allows it, real file written to `workspace/hello.txt` +- `read_file`: Cedar allows it, reads the file back +- `list_dir`: **denied by Cedar policy** (HTTP 403, error_code: POLICY_DENY) +- TRACE claim shows `runtime.platform`, `runtime.measurement`, `policy.bundle_hash` +- Claim saved to `workspace/trace-claim.json` (needed by demos 2 and 3) + +--- + +## Demo 2 -- Policy swap = attestation failure (~90 seconds) + +The operator loads a different Cedar policy bundle (v1 vs v2). The claim's `policy.bundle_hash` changes. A verifier that pinned the v1 hash rejects v2 claims with `POLICY_HASH_MISMATCH`. + +On real TDX hardware, the policy hash flows into RTMR[2] at startup -- the TEE measurement itself changes, not just a field in the claim. + +**Requires demo 1 to have run first** (reads `workspace/trace-claim.json`). + +``` +python demo-02-policy-swap/run.py +``` + +What you see: +- v1 and v2 bundle hashes printed (visibly different SHA-256 values) +- `write_file` is DENIED under v2 policy (new Cedar forbid rule) +- `cmcp verify` on the v2 claim with the pinned v1 hash -> `policy_bundle.hash` FAILS (POLICY_HASH_MISMATCH) +- `cmcp verify` on the v2 claim with the v2 hash -> `policy_bundle.hash` PASSES (overall result stays `partially_verified` in software-only mode, because only `hardware_attestation` is left unchecked) + +--- + +## Demo 3 -- Offline TRACE verification (~60 seconds) + +The signed TRACE claim from demo-01 is verified with no gateway, no server, and no network call. Only `cmcp_verify` and the hashes embedded in the claim are used. + +**Requires demo 1 to have run first**. + +``` +python demo-03-offline-trace/run.py +``` + +What you see: +- Claim fields printed: `runtime.platform`, `runtime.measurement`, `policy.bundle_hash` +- All cryptographic checks pass (schema, signature, policy hash, catalog hash, audit chain) +- `hardware_attestation` is in `unverified_fields`, so the status reads `partially_verified` (software-only mode -- on real TDX that field verifies too and the status becomes `verified`) +- No connection made to any server + +--- + +## Demo 4 -- Context-aware enforcement (~90 seconds) + +Demos 1-3 are the trust chain: enforce, tamper, verify. Demo 4 is a second axis. The same tool is allowed in one workflow and denied in another, so authorization tracks the declared call context, not the tool's identity and not the model's stated intent. + +``` +python demo-04-context-enforcement/run.py +``` + +What you see: +- `write_file` under `workflow_id="invoice-run"`: Cedar **allows** it (the `WriteFile` permit is guarded by `context.workflow_id == "invoice-run"`) +- the identical `write_file`, same arguments, under `workflow_id="chat-freeform"`: **denied by Cedar** (HTTP 403, POLICY_DENY) -- no permit matches, default-deny holds +- `read_file` under `workflow_id="chat-freeform"`: **allowed** (reads are permitted in any workflow, so the second workflow is not blocked wholesale; only the write capability is scoped) +- both the allow and the deny are committed to the signed audit chain under one `policy.bundle_hash` + +--- + +## Demo 5 -- Attribute-based enforcement (~90 seconds) + +Demo 1 denies by tool name, demo 4 by call context. Demo 5 denies by the tool's compliance attributes. A tool that is not BAA-covered is refused by a single guardrail rule, whatever it is named. + +``` +python demo-05-compliance-domain/run.py +``` + +What you see: +- `write_file` and `read_file` (`compliance_domain=clinical`, `baa_covered=true`): **allowed** +- `list_dir` (`compliance_domain=external-analytics`, `baa_covered=false`): **denied by Cedar** (HTTP 403, POLICY_DENY) +- the deny comes from `forbid ... when { context.baa_covered == false }`, which overrides the baseline permit; the call is refused on its attribute, not its name +- one rule covers every non-BAA-covered tool in the catalog, present and future + +--- + +## Demo 6 -- Weight custody (~60 seconds) + +Demos 1-5 govern what an agent *does*. Demo 6 is the layer beneath: the model *weights* themselves. A Weight Custody Manifest binds the exact weight hash, gates the decryption key behind attestation, and carries the fine-tune's lineage. It needs no hardware and no server, and depends only on the `weight-custody-manifest` package from PyPI. + +``` +python demo-06-weight-custody/run.py +``` + +What you see: +- a manifest jointly signed (builder + custodian) binds the checkpoint's exact `weights_hash`, and the signature verifies +- the attestation gate releases the key only for the certified serving stack (genuine nonce, approved platform, signed image measurement) +- a tampered checkpoint's hash does not match the manifest, so it is **refused before it ever loads** +- a fine-tune is a derivative whose lineage verifies back to the signed base (the derivative is the real IP) +- honest scope: this is accountability-grade against an operator who physically owns the silicon (see TEE.fail), not silicon-proof custody + +--- + +## Demo 7 -- Closed-weight custody (~60 seconds) + +The mirror of demo 6. There the base was an open-weight model, so the machinery did integrity, license, and derivative custody. Here the model is **closed** (a frontier lab shipping weights into a customer's or sovereign's own enclave), so the base weights *are* the secret and the job is to keep the decryption key off the operator. + +``` +python demo-07-closed-weight/run.py +``` + +What you see: +- a manifest with `base_confidentiality: confidential`, jointly signed by the lab and the customer +- the decryption key releases only into the attested, lab-signed serving stack +- an unapproved serving stack (one that could export plaintext weights) is refused the key +- the closed-vs-open contrast spelled out: same protocol, different job + +--- + +## Demo 8 -- Derivative lineage (~60 seconds) + +When a customer fine-tunes inside the enclave on private data, the result is novel IP that never existed publicly. WCM gives the derivative its own signed manifest with a `derived_from` pointer and a `rights_holder` split, so its chain of custody resolves back to the base. + +``` +python demo-08-derivative-lineage/run.py +``` + +What you see: +- a base manifest permitting `fine-tune-only`, and a derivative that permits `none` +- `verify_lineage` resolving the derivative back to the base (chain, depth, root) +- the derivative's `rights_holder` recording the base/derivative IP split +- monotone rights: a fork of the no-derivatives derivative is **rejected**, the base's terms travel down the chain + +--- + +## Demo 9 -- Sovereign threshold (~90 seconds) + +For a sovereign deployment the honest limit bites hardest: a hardware owner who forges one attestation could release a key. The answer is to never let one release be enough. The model key is split **2-of-3** across independent parties, each releasing its share only against its own attestation. + +``` +python demo-09-sovereign-threshold/run.py +``` + +What you see: +- the model key split 2-of-3 across the lab, the sovereign authority, and the customer +- a single share reconstructs nothing (one forged attestation is below threshold) +- two independent shareholders each attest and release their share +- the quorum reconstructs the key, so forging it requires forging attestation to a quorum of independent roots, not one + +--- + +## Demo 10 -- Governed model calls (~90 seconds) + +The other nine demos govern **tool** calls. This one governs **model** calls, a different +question: not whether an agent may use a tool, but whether this class of data may reach +that model, in that region, on that infrastructure. The caller changes one line, +`base_url`, and keeps its existing OpenAI client. + +``` +python demo-10-model-gateway/run.py +``` + +What you see: +- `public` data reaching an out-of-region model, allowed and recorded +- `pii` allowed out of region **only after** identifiers are stripped at the boundary +- `confidential` refused out of region: proof makes a call provable, not permissible +- `hipaa_phi` refused in region on **shared** infrastructure and allowed on **dedicated** -- same prompt, same class, so jurisdiction is necessary but not sufficient +- the session closing into a signed TRACE claim, verifiable offline + +--- + +## Structure + +``` +demos/ ++-- README.md ++-- requirements.txt ++-- server/ +| +-- server.py # Plain HTTP JSON-RPC 2.0 server: write_file, read_file, list_dir +| +-- requirements.txt ++-- workspace/ # Files written by demos (created at runtime) ++-- demo-01-cmcp-in-action/ +| +-- cmcp-config.yaml +| +-- catalog.json # Approves write_file, read_file, list_dir +| +-- policies/ # Cedar: permit write_file+read_file, forbid list_dir +| +-- call.py # Demo agent: calls tools, closes session, saves claim +| +-- run.py # Cross-platform launcher (use this) +| +-- run.sh # bash-only launcher ++-- demo-02-policy-swap/ +| +-- catalog.json +| +-- cmcp-config.yaml # Points to policies-v1/ +| +-- cmcp-config-v2.yaml # Points to policies-v2/ +| +-- policies-v1/ # Cedar: permit all +| +-- policies-v2/ # Cedar: deny write_file (hash differs from v1) +| +-- check_hash.py # Computes and compares bundle hashes +| +-- run.py # Cross-platform launcher (use this) +| +-- run.sh # bash-only launcher ++-- demo-03-offline-trace/ +| +-- verify.py # cmcp_verify.verify_trace_claim (no network) +| +-- run.py # Cross-platform launcher (use this) +| +-- run.sh # bash-only launcher ++-- demo-04-context-enforcement/ +| +-- cmcp-config.yaml +| +-- catalog.json # Approves write_file, read_file (compliance_domain: finance) +| +-- policies/ # Cedar: WriteFile permitted only when context.workflow_id == "invoice-run" +| +-- call.py # Same write in two workflows: one allowed, one denied +| +-- run.py # Cross-platform launcher (use this) +| +-- run.sh # bash-only launcher ++-- demo-05-compliance-domain/ +| +-- cmcp-config.yaml +| +-- catalog.json # Tags tools with compliance_domain + BAA status +| +-- policies/ # Cedar: forbid any tool when context.baa_covered == false +| +-- call.py # Two BAA-covered tools allowed, one non-covered tool denied +| +-- run.py # Cross-platform launcher (use this) +| +-- run.sh # bash-only launcher ++-- demo-06-weight-custody/ +| +-- run.py # WCM flow: sign, attestation gate, tamper refusal, lineage ++-- demo-07-closed-weight/ +| +-- run.py # Closed-weight secrecy: key releases only into the attested stack ++-- demo-08-derivative-lineage/ +| +-- run.py # The fine-tune is the IP: derived_from + rights split + lineage ++-- demo-09-sovereign-threshold/ + +-- run.py # 2-of-3 threshold split, each share released against attestation +``` + +--- + +## How it connects + +``` +Agent (call.py) + | + v POST /mcp Authorization: Bearer +[cMCP runtime :8443] <-- Cedar policy bundle (hash -> RTMR[2] on TDX) + | + v POST http://localhost:9001/mcp (JSON-RPC 2.0) +[server/server.py :9001] --> workspace/ (real file writes) + | +POST /sessions/{id}/close + | + v +TRACE claim (Ed25519 signed) --> workspace/trace-claim.json + | + v +cmcp_verify.verify_trace_claim() (no network, no operator trust) +``` diff --git a/demos/SECURITY.md b/demos/SECURITY.md new file mode 100644 index 00000000..523aeaa9 --- /dev/null +++ b/demos/SECURITY.md @@ -0,0 +1,39 @@ +# Security Policy + +## Reporting a Vulnerability + +**Do not open a public GitHub issue for security vulnerabilities.** + +Report privately via [GitHub Security Advisories](https://github.com/agentrust-io/integrations/security/advisories/new). You will receive a confirmation within 2 business days and a triage decision within 5 business days. + +## Response SLAs + +| Severity | Definition | Fix Target | +|----------|------------|------------| +| Critical | A demo that reports a control as enforced when it is not, or that would leak a real credential if followed as written | 30 days from confirmed report | +| High / Medium / Low | All other confirmed vulnerabilities | 90 days from confirmed report | + +The timeline starts when the issue is confirmed as a valid vulnerability, not on initial receipt. + +## Scope + +This repository is demonstration code, which shapes what counts as a vulnerability here rather than excusing it. Two things are firmly in scope: + +- **A demo whose output misrepresents what was enforced.** The point of these demos is to show a policy denying something, a gate holding, or evidence verifying. A demo that prints a success heading over a failed check is a security defect in this repository, not a cosmetic bug, because the whole artifact is a claim about behaviour. One of these was fixed in August 2026, where demo-06 printed `gate released key: False` under a heading that claimed the opposite. +- **A demo that teaches an unsafe pattern.** Code people copy is code people ship. Disabled verification, an ignored return value, a hardcoded trust anchor presented as normal practice: report it. + +Also in scope: anything in this repository that would expose a real credential, endpoint, or customer identifier if run as written. + +## Not in scope + +- **The demo services themselves as production systems.** They bind local ports, use throwaway keys, and are not hardened for exposure to a network. Report an unsafe *default* that a reader would carry into production; do not report that a demo gateway on localhost has no rate limiting. +- **Vulnerabilities in the upstream projects being demonstrated.** Those belong to agentrust-io/cmcp, agentrust-io/agent-manifest and the other component repositories, each of which has its own policy. If the demo misuses one of them, that part is ours. +- **Synthetic fixtures and sample keys**, which exist to be published and are not secrets. + +## What to include + +Which demo, the command you ran, what it printed, and what it should have printed. For a misreporting bug, the output is the evidence. + +## Disclosure + +We prefer coordinated disclosure. Tell us if you intend to publish and we will agree a date. We will credit you in the advisory unless you ask us not to. diff --git a/demos/SPONSORS.md b/demos/SPONSORS.md new file mode 100644 index 00000000..539004dc --- /dev/null +++ b/demos/SPONSORS.md @@ -0,0 +1,20 @@ +# Sponsors + +AgenTrust Demos is an open-source project. Sponsors provide funding, +engineering time, infrastructure, or other in-kind support. Sponsorship does +not confer project ownership, governance authority, or control over technical +decisions or demo results. + +## Current sponsors + +### OPAQUE Systems + +Founding engineering and infrastructure sponsor, and contributor to the +AgenTrust demo ecosystem. + +## Recognition policy + +Sponsor recognition is separate from demo authorship and scenario provenance. +Vendor names and identities inside manifests, policies, fixtures, custody +roles, and deployment profiles describe the scenario being demonstrated; they +do not identify the owner of this repository or its projects. diff --git a/demos/demo-01-cmcp-in-action/README.md b/demos/demo-01-cmcp-in-action/README.md new file mode 100644 index 00000000..5c58fd43 --- /dev/null +++ b/demos/demo-01-cmcp-in-action/README.md @@ -0,0 +1,25 @@ +# Demo 1: cMCP in action + +**Duration:** ~90 seconds + +Cedar enforced in enclave. Every tool call produces a TRACE claim carrying the policy bundle hash. On real Intel TDX hardware this hash is incorporated into RTMR[2]; with `CMCP_DEV_MODE=1` the TEE is software-only but the hash still appears in `policy.bundle_hash` in the claim. + +## Run + +```bash +bash run.sh +``` + +## What to show the audience + +1. cMCP starts — note the policy bundle hash printed at startup +2. `write_file` call goes through — Cedar evaluated it as permitted +3. TRACE claim in the response shows: + - `runtime.platform: software-only` (would be `intel-tdx` on real hardware) + - `runtime.measurement: sha256:000...` (all-zeros in dev mode; real hardware gives a non-zero sha384) + - `policy.bundle_hash: sha256:...` — this is the hash that would go into RTMR[2] +4. `workspace/hello.txt` is created — real filesystem write under governance + +## Policy + +`policies/allow-filesystem-tools.cedar` permits `write_file` and `read_file` for any principal. Nothing else in the catalog is callable. diff --git a/demos/demo-01-cmcp-in-action/call.py b/demos/demo-01-cmcp-in-action/call.py new file mode 100644 index 00000000..d9a022c4 --- /dev/null +++ b/demos/demo-01-cmcp-in-action/call.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Demo 1: calls tools through cMCP, shows Cedar enforcement, saves TRACE claim. + +Prerequisites: + server/server.py running on :9001 (plain HTTP JSON-RPC 2.0) + cmcp start --config cmcp-config.yaml running on :8443 + CMCP_BEARER_TOKEN env var set (run.sh exports this) + +Usage: + python call.py (run from repo root, or via run.sh) +""" +import json +import os +import pathlib +import sys +import time +import urllib.error +import urllib.request + +GATEWAY_URL = "http://localhost:8443" +TOKEN = os.environ.get("CMCP_BEARER_TOKEN", "demo-token") +WORKSPACE = pathlib.Path(__file__).parent.parent / "workspace" + + +def _headers(): + return {"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}"} + + +def _post(url, payload): + data = json.dumps(payload).encode() + req = urllib.request.Request(url, data=data, headers=_headers(), method="POST") + try: + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read()), resp.status + except urllib.error.HTTPError as exc: + return json.loads(exc.read()), exc.code + + +def mcp_call(name, arguments): + return _post(f"{GATEWAY_URL}/mcp", { + "jsonrpc": "2.0", + "id": int(time.time() * 1000), + "method": "tools/call", + "params": { + "name": name, + "arguments": arguments, + "_cmcp": {"workflow_id": "demo-01"}, + }, + }) + + +def main(): + WORKSPACE.mkdir(exist_ok=True) + session_id = None + + print("=== Demo 1: cMCP in action ===\n") + + # write_file -- Cedar: permit + print("[1/3] write_file [Cedar: permit]") + body, status = mcp_call("write_file", { + "path": "hello.txt", + "content": "Written under Cedar enforcement at agentrust CC Summit 2026\n", + }) + if status == 200: + text = body["result"]["content"][0]["text"] + cmcp = body["result"].get("_cmcp", {}) + session_id = cmcp.get("session_id") + print(f" ALLOWED result={text!r}") + print(f" session_id={session_id} latency={cmcp.get('latency_us')} us") + else: + print(f" ERROR {status}: {body}") + sys.exit(1) + + # read_file -- Cedar: permit + print() + print("[2/3] read_file [Cedar: permit]") + body, status = mcp_call("read_file", {"path": "hello.txt"}) + if status == 200: + text = body["result"]["content"][0]["text"] + session_id = body["result"].get("_cmcp", {}).get("session_id") or session_id + print(f" ALLOWED content={text.strip()!r}") + else: + print(f" ERROR {status}: {body}") + + # list_dir -- Cedar: forbid + print() + print("[3/3] list_dir [Cedar: forbid]") + body, status = mcp_call("list_dir", {}) + if status == 403: + error = body.get("error", {}) + code = error.get("data", {}).get("error_code", "?") + print(f" HTTP 403 -- {error.get('message', '?')} [{code}]") + print(" Cedar forbid matched: resource.tool_name == \"list_dir\"") + else: + print(f" unexpected {status}: {body}") + + # close session -> signed TRACE claim + print() + print("Closing session -> TRACE claim...") + if not session_id: + print("ERROR: no session_id. Is cMCP returning _cmcp metadata?") + sys.exit(1) + + body, status = _post(f"{GATEWAY_URL}/sessions/{session_id}/close", {}) + if status != 200: + print(f"ERROR {status}: {body}") + sys.exit(1) + claim = body + + trace = claim.get("trace", {}) + gw = claim.get("gateway", {}) + chain = gw.get("audit_chain", {}) + + print() + print("=== TRACE claim ===") + print(f" runtime.platform: {trace.get('runtime', {}).get('platform')}") + print(f" runtime.measurement: {trace.get('runtime', {}).get('measurement')}") + print(f" policy.bundle_hash: {trace.get('policy', {}).get('bundle_hash')}") + print(f" policy.mode: {trace.get('policy', {}).get('enforcement_mode')}") + print(f" catalog.hash: {gw.get('catalog', {}).get('hash')}") + print(f" audit_chain.length: {chain.get('length')}") + sig = claim.get("signature", "") + print(f" signature: {sig[:40]}...") + print() + print(" policy.bundle_hash is committed in the Ed25519 signature.") + print(" On real TDX: it also flows into RTMR[2] at cMCP Runtime startup.") + + claim_path = WORKSPACE / "trace-claim.json" + claim_path.write_text(json.dumps(claim, indent=2)) + print() + print(f" Claim saved to {claim_path}") + print(" Used by demo-02 and demo-03.") + + +if __name__ == "__main__": + main() diff --git a/demos/demo-01-cmcp-in-action/catalog.json b/demos/demo-01-cmcp-in-action/catalog.json new file mode 100644 index 00000000..8262ec13 --- /dev/null +++ b/demos/demo-01-cmcp-in-action/catalog.json @@ -0,0 +1,94 @@ +[ + { + "tool_name": "write_file", + "server": { + "display_name": "Demo Filesystem MCP Server", + "url": "http://localhost:9001/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "rotation_mode": "key-pinned", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Write content to a file in the demo workspace", + "input_schema": { + "type": "object", + "required": [ + "path", + "content" + ], + "properties": { + "path": { + "type": "string" + }, + "content": { + "type": "string" + } + } + } + }, + "definition_hash": "sha256:a1e420dc7faa479147d132a69f55e12cfdb5f52e215f2bff78a8877924c8322b", + "compliance_domain": "public", + "requires_baa": false, + "sensitivity_level": "public", + "added_at": "2026-06-22T00:00:00Z", + "approved_by": "demo-setup" + }, + { + "tool_name": "read_file", + "server": { + "display_name": "Demo Filesystem MCP Server", + "url": "http://localhost:9001/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "rotation_mode": "key-pinned", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Read a file from the demo workspace", + "input_schema": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + } + } + } + }, + "definition_hash": "sha256:b62b2bd456a551a8cc0f70dce71b06e358c73a8f16b8220c1b8fea7ae8b1a0e4", + "compliance_domain": "public", + "requires_baa": false, + "sensitivity_level": "public", + "added_at": "2026-06-22T00:00:00Z", + "approved_by": "demo-setup" + }, + { + "tool_name": "list_dir", + "server": { + "display_name": "Demo Filesystem MCP Server", + "url": "http://localhost:9001/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "rotation_mode": "key-pinned", + "transport": "http-sse" + }, + "approved_definition": { + "description": "List files in the demo workspace", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "default": "" + } + } + } + }, + "definition_hash": "sha256:b3a1c4bd34d6f8fbb816d7c0ddad7997cdc1f319daba1fd5d1b0fff3821b04c5", + "compliance_domain": "public", + "requires_baa": false, + "sensitivity_level": "public", + "added_at": "2026-06-22T00:00:00Z", + "approved_by": "demo-setup" + } +] diff --git a/demos/demo-01-cmcp-in-action/cmcp-config.yaml b/demos/demo-01-cmcp-in-action/cmcp-config.yaml new file mode 100644 index 00000000..7590dd0b --- /dev/null +++ b/demos/demo-01-cmcp-in-action/cmcp-config.yaml @@ -0,0 +1,9 @@ +attestation: + provider: auto # falls back to software-only when CMCP_DEV_MODE=1 + enforcement_mode: enforcing + validity_seconds: 3600 + +policy_bundle_path: ./policies/ +catalog_path: ./catalog.json +listen_addr: "127.0.0.1:8443" +audit_db_path: ./audit.db diff --git a/demos/demo-01-cmcp-in-action/policies/allow-filesystem-tools.cedar b/demos/demo-01-cmcp-in-action/policies/allow-filesystem-tools.cedar new file mode 100644 index 00000000..98a7e17d --- /dev/null +++ b/demos/demo-01-cmcp-in-action/policies/allow-filesystem-tools.cedar @@ -0,0 +1,21 @@ +// cMCP Cedar policy for demo-01 +// +// AGT's CedarBackend maps tool_name to a Cedar action (PascalCase): +// write_file -> Action::"WriteFile" +// read_file -> Action::"ReadFile" +// list_dir -> Action::"ListDir" +// +// write_file and read_file: ALLOWED +// list_dir: DENIED (demonstrates Cedar enforcement) + +permit ( + principal, + action in [Action::"WriteFile", Action::"ReadFile"], + resource +); + +forbid ( + principal, + action == Action::"ListDir", + resource +); diff --git a/demos/demo-01-cmcp-in-action/policies/manifest.json b/demos/demo-01-cmcp-in-action/policies/manifest.json new file mode 100644 index 00000000..b27f4495 --- /dev/null +++ b/demos/demo-01-cmcp-in-action/policies/manifest.json @@ -0,0 +1,7 @@ +{ + "version": "1.0.0", + "authored_at": "2026-06-22T00:00:00Z", + "author_identity": "demo-setup", + "commit_sha": "demo-do-not-use-in-production", + "approval_chain": [] +} diff --git a/demos/demo-01-cmcp-in-action/policies/schema.cedarschema b/demos/demo-01-cmcp-in-action/policies/schema.cedarschema new file mode 100644 index 00000000..a3b37be6 --- /dev/null +++ b/demos/demo-01-cmcp-in-action/policies/schema.cedarschema @@ -0,0 +1,67 @@ +{ + "cMCP": { + "entityTypes": { + "Principal": { + "memberOfTypes": [], + "shape": { + "type": "Record", + "attributes": { + "session_id": { + "type": "String", + "required": true + }, + "workflow_id": { + "type": "String", + "required": true + } + } + } + }, + "Resource": { + "memberOfTypes": [], + "shape": { + "type": "Record", + "attributes": { + "tool_name": { + "type": "String", + "required": true + }, + "compliance_domain": { + "type": "String", + "required": true + }, + "baa_covered": { + "type": "Bool", + "required": true + } + } + } + } + }, + "actions": { + "call_tool": { + "appliesTo": { + "principalTypes": [ + "cMCP::Principal" + ], + "resourceTypes": [ + "cMCP::Resource" + ], + "context": { + "type": "Record", + "attributes": { + "session_max_sensitivity": { + "type": "String", + "required": true + }, + "workflow_id": { + "type": "String", + "required": true + } + } + } + } + } + } + } +} diff --git a/demos/demo-01-cmcp-in-action/run.py b/demos/demo-01-cmcp-in-action/run.py new file mode 100644 index 00000000..85cebb7f --- /dev/null +++ b/demos/demo-01-cmcp-in-action/run.py @@ -0,0 +1,161 @@ +"""Demo 1: cMCP in action — cross-platform launcher (replaces run.sh). + +Usage: + python demo-01-cmcp-in-action/run.py # from repo root + python run.py # from demo-01 directory +""" +import os +import pathlib +import shutil +import socket +import subprocess +import sys +import time + +sys.stdout.reconfigure(line_buffering=True) + +SCRIPT_DIR = pathlib.Path(__file__).parent.resolve() +REPO_ROOT = SCRIPT_DIR.parent + + +def _find_cmcp() -> str: + found = shutil.which("cmcp") + if found: + return found + import sysconfig + candidates = [ + pathlib.Path(sys.executable).parent, + pathlib.Path(sysconfig.get_path("scripts")), + pathlib.Path(sysconfig.get_path("scripts", "nt_user")), + ] + for scripts in candidates: + for name in ("cmcp.exe", "cmcp"): + p = scripts / name + if p.exists(): + return str(p) + sys.exit("cmcp not found. Run: pip install cmcp-runtime") + + + +def _log_tail(what: str, lines: int = 15) -> str: + """Return the tail of the log for `what`, or "" if there is nothing to show. + + A service that fails to bind has already written the reason to its log. The + launcher used to point at the file and leave; printing the tail turns a 60 + second wait plus a scavenger hunt into the actual error. + """ + name = "cmcp.log" if "cMCP" in what or "cmcp" in what else "server.log" + path = pathlib.Path(__file__).parent.resolve() / name + try: + tail = path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:] + except OSError: + return "" + if not tail: + return "" + body = "\n".join(" " + line for line in tail) + return f"\n\nLast {len(tail)} lines of {name}:\n{body}" + +def _wait_for_port(port: int, what: str, process=None, timeout: float = 60.0) -> None: + """Block until something is listening on 127.0.0.1:port. + + A fixed sleep used to be enough; cMCP Runtime now does attestation and audit + setup before it binds, so the demo raced startup and failed on a refused + connection. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process is not None and process.poll() is not None: + sys.exit(f"{what} exited with code {process.returncode} before listening on :{port}." + + _log_tail(what)) + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + return + except OSError: + time.sleep(0.25) + sys.exit(f"{what} did not start listening on :{port} within {timeout:.0f}s." + + _log_tail(what)) + + +def _assert_port_free(port: int, what: str) -> None: + """Refuse to start if something already owns the port. + + _wait_for_port() returns as soon as *anything* answers, so a gateway left + running by an earlier demo satisfies it instantly. Every call then goes to + that gateway and is decided by its policy bundle, not this demo's. The + verdicts still look plausible, which is what makes it dangerous: the demo + prints allow/deny lines that are simply wrong, with no error anywhere. + """ + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + pass + except OSError: + return + sys.exit( + f"Port {port} is already in use, so {what} cannot start and this demo " + f"would be scored against whatever is already listening. Stop it first " + f"(a cMCP gateway left over from another demo is the usual cause), then " + f"re-run." + ) + + +def _wait_for_port_release(port: int, timeout: float = 10.0) -> None: + """Block until the port is actually free, so the next demo starts clean.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + time.sleep(0.25) + except OSError: + return + print(f"warning: port {port} still held after teardown", file=sys.stderr) + + +def main() -> None: + # Before anything starts: both ports must be ours. + _assert_port_free(9001, "the MCP filesystem server") + _assert_port_free(8443, "the cMCP Runtime") + + token = os.environ.setdefault("CMCP_BEARER_TOKEN", "demo-token") # noqa: F841 + + log_dir = SCRIPT_DIR + server_log = open(log_dir / "server.log", "w") + cmcp_log = open(log_dir / "cmcp.log", "w") + + server = cmcp_proc = None + try: + print("-- Starting MCP filesystem server on :9001 --", flush=True) + server = subprocess.Popen( + [sys.executable, str(REPO_ROOT / "server" / "server.py")], + stdout=server_log, stderr=server_log, + ) + _wait_for_port(9001, "MCP filesystem server", server) + + print("-- Starting cMCP Runtime (CMCP_DEV_MODE=1) on :8443 --", flush=True) + env = os.environ.copy() + env["CMCP_DEV_MODE"] = "1" + cmcp_proc = subprocess.Popen( + [_find_cmcp(), "start", "--config", str(SCRIPT_DIR / "cmcp-config.yaml")], + stdout=cmcp_log, stderr=cmcp_log, + cwd=SCRIPT_DIR, env=env, + ) + _wait_for_port(8443, "cMCP Runtime", cmcp_proc) + + print() + subprocess.run([sys.executable, str(SCRIPT_DIR / "call.py")], check=True) + + finally: + for proc in (cmcp_proc, server): + if proc and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + _wait_for_port_release(8443) + _wait_for_port_release(9001) + server_log.close() + cmcp_log.close() + + +if __name__ == "__main__": + main() diff --git a/demos/demo-01-cmcp-in-action/run.sh b/demos/demo-01-cmcp-in-action/run.sh new file mode 100644 index 00000000..21ac7d29 --- /dev/null +++ b/demos/demo-01-cmcp-in-action/run.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Demo 1: cMCP in action +# +# Thin wrapper around run.py, which is the cross-platform launcher and the one +# CI exercises. This script used to start the servers itself with a fixed +# `sleep 2`, which raced cMCP startup and, worse, carried no check that the +# ports were free. Keeping the logic in one place means the shell path and the +# CI path cannot drift apart again. +# +# Usage: bash demo-01-cmcp-in-action/run.sh (from repo root) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python "$SCRIPT_DIR/run.py" "$@" diff --git a/demos/demo-02-policy-swap/README.md b/demos/demo-02-policy-swap/README.md new file mode 100644 index 00000000..e4787d76 --- /dev/null +++ b/demos/demo-02-policy-swap/README.md @@ -0,0 +1,30 @@ +# Demo 2: Policy swap = attestation failure + +**Duration:** ~90 seconds + +The operator swaps the Cedar policy bundle (v1 → v2). The TRACE claim's `policy.bundle_hash` changes immediately. Any verifier that pinned the v1 hash will reject all claims produced by the reloaded runtime — the policy identity is no longer what was approved. + +On real Intel TDX hardware, the policy hash is incorporated into RTMR[2] at startup. Swapping the bundle means the TEE measurement changes — so the attestation quote itself mismatches, before any claim is evaluated. + +## Run + +```bash +bash run.sh +``` + +## What to show the audience + +1. `check_hash.py` output: v1 hash and v2 hash printed side-by-side — visibly different values +2. cMCP starts with v1 → `write_file` succeeds, TRACE claim shows v1 hash +3. Operator restarts cMCP with v2 → `write_file` is DENIED (Cedar `forbid` rule) +4. TRACE claim from the v2 run shows a different `policy.bundle_hash` +5. Key point: any system that approved v1's hash would reject v2's claims — policy substitution is detectable + +## Policies + +| Bundle | Cedar rules | +|---|---| +| `policies-v1/` | `permit` all catalog tools | +| `policies-v2/` | `permit read_file`, `forbid write_file` | + +The only change is one Cedar file. The bundle hash changes completely (sha256 over the entire bundle content). diff --git a/demos/demo-02-policy-swap/catalog.json b/demos/demo-02-policy-swap/catalog.json new file mode 100644 index 00000000..e7f8ad56 --- /dev/null +++ b/demos/demo-02-policy-swap/catalog.json @@ -0,0 +1,66 @@ +[ + { + "tool_name": "write_file", + "server": { + "display_name": "Demo Filesystem MCP Server", + "url": "http://localhost:9001/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "rotation_mode": "key-pinned", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Write content to a file in the demo workspace", + "input_schema": { + "type": "object", + "required": [ + "path", + "content" + ], + "properties": { + "path": { + "type": "string" + }, + "content": { + "type": "string" + } + } + } + }, + "definition_hash": "sha256:a1e420dc7faa479147d132a69f55e12cfdb5f52e215f2bff78a8877924c8322b", + "compliance_domain": "public", + "requires_baa": false, + "sensitivity_level": "public", + "added_at": "2026-06-22T00:00:00Z", + "approved_by": "demo-setup" + }, + { + "tool_name": "read_file", + "server": { + "display_name": "Demo Filesystem MCP Server", + "url": "http://localhost:9001/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "rotation_mode": "key-pinned", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Read a file from the demo workspace", + "input_schema": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + } + } + } + }, + "definition_hash": "sha256:b62b2bd456a551a8cc0f70dce71b06e358c73a8f16b8220c1b8fea7ae8b1a0e4", + "compliance_domain": "public", + "requires_baa": false, + "sensitivity_level": "public", + "added_at": "2026-06-22T00:00:00Z", + "approved_by": "demo-setup" + } +] diff --git a/demos/demo-02-policy-swap/check_hash.py b/demos/demo-02-policy-swap/check_hash.py new file mode 100644 index 00000000..3cab27d8 --- /dev/null +++ b/demos/demo-02-policy-swap/check_hash.py @@ -0,0 +1,76 @@ +""" +Compute the cMCP policy bundle hash for policies-v1 and policies-v2. + +Matches the exact algorithm in cmcp_runtime.policy.bundle._canonical_bundle_hash: + + sha256(json({ + "manifest": , + "policy_files": {: sha256(file_text.encode()), ...}, # sorted + "schema_hash": sha256(schema_text.encode()) + }, sort_keys=True, separators=(",",":"))) + +Usage: + python check_hash.py + python check_hash.py v1 # print only v1 + python check_hash.py v2 # print only v2 +""" +import hashlib +import json +import pathlib +import sys + + +def sha256_hex(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def bundle_hash(policies_dir: pathlib.Path) -> str: + raw_manifest = json.loads((policies_dir / "manifest.json").read_text()) + + # Glob all .cedar files, sorted, relative paths as POSIX strings + cedar_files = sorted(policies_dir.glob("**/*.cedar")) + policy_files = { + cf.relative_to(policies_dir).as_posix(): sha256_hex(cf.read_text().encode()) + for cf in cedar_files + } + + schema_content = (policies_dir / "schema.cedarschema").read_text() + + canonical = json.dumps( + { + "manifest": raw_manifest, + "policy_files": dict(sorted(policy_files.items())), + "schema_hash": sha256_hex(schema_content.encode()), + }, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode() + + return "sha256:" + sha256_hex(canonical) + + +def main(): + base = pathlib.Path(__file__).parent + targets = {"v1": base / "policies-v1", "v2": base / "policies-v2"} + + filter_arg = sys.argv[1] if len(sys.argv) > 1 else None + + hashes = {} + for label, path in targets.items(): + h = bundle_hash(path) + hashes[label] = h + if filter_arg is None or filter_arg == label: + print(f"{label} {h}") + + if filter_arg is None: + print() + if hashes["v1"] != hashes["v2"]: + print("Hashes differ -- policy swap is detectable by any verifier") + print("that pinned the v1 hash.") + else: + print("WARNING: hashes are identical") + + +if __name__ == "__main__": + main() diff --git a/demos/demo-02-policy-swap/cmcp-config-v2.yaml b/demos/demo-02-policy-swap/cmcp-config-v2.yaml new file mode 100644 index 00000000..3be1dc88 --- /dev/null +++ b/demos/demo-02-policy-swap/cmcp-config-v2.yaml @@ -0,0 +1,9 @@ +attestation: + provider: auto + enforcement_mode: enforcing + validity_seconds: 3600 + +policy_bundle_path: ./policies-v2/ +catalog_path: ./catalog.json +listen_addr: "127.0.0.1:8443" +audit_db_path: ./audit-v2.db diff --git a/demos/demo-02-policy-swap/cmcp-config.yaml b/demos/demo-02-policy-swap/cmcp-config.yaml new file mode 100644 index 00000000..cfa24845 --- /dev/null +++ b/demos/demo-02-policy-swap/cmcp-config.yaml @@ -0,0 +1,11 @@ +attestation: + provider: auto + enforcement_mode: enforcing + validity_seconds: 3600 + +# policy_bundle_path is set via CMCP_POLICY_BUNDLE_PATH env var in the demo +# so we can swap between policies-v1 and policies-v2 without editing this file +policy_bundle_path: ./policies-v1/ +catalog_path: ./catalog.json +listen_addr: "127.0.0.1:8443" +audit_db_path: ./audit.db diff --git a/demos/demo-02-policy-swap/policies-v1/allow-all.cedar b/demos/demo-02-policy-swap/policies-v1/allow-all.cedar new file mode 100644 index 00000000..f90110a5 --- /dev/null +++ b/demos/demo-02-policy-swap/policies-v1/allow-all.cedar @@ -0,0 +1,2 @@ +// v1 policy: allow all catalog tools +permit (principal, action, resource); diff --git a/demos/demo-02-policy-swap/policies-v1/manifest.json b/demos/demo-02-policy-swap/policies-v1/manifest.json new file mode 100644 index 00000000..b27f4495 --- /dev/null +++ b/demos/demo-02-policy-swap/policies-v1/manifest.json @@ -0,0 +1,7 @@ +{ + "version": "1.0.0", + "authored_at": "2026-06-22T00:00:00Z", + "author_identity": "demo-setup", + "commit_sha": "demo-do-not-use-in-production", + "approval_chain": [] +} diff --git a/demos/demo-02-policy-swap/policies-v1/schema.cedarschema b/demos/demo-02-policy-swap/policies-v1/schema.cedarschema new file mode 100644 index 00000000..a3b37be6 --- /dev/null +++ b/demos/demo-02-policy-swap/policies-v1/schema.cedarschema @@ -0,0 +1,67 @@ +{ + "cMCP": { + "entityTypes": { + "Principal": { + "memberOfTypes": [], + "shape": { + "type": "Record", + "attributes": { + "session_id": { + "type": "String", + "required": true + }, + "workflow_id": { + "type": "String", + "required": true + } + } + } + }, + "Resource": { + "memberOfTypes": [], + "shape": { + "type": "Record", + "attributes": { + "tool_name": { + "type": "String", + "required": true + }, + "compliance_domain": { + "type": "String", + "required": true + }, + "baa_covered": { + "type": "Bool", + "required": true + } + } + } + } + }, + "actions": { + "call_tool": { + "appliesTo": { + "principalTypes": [ + "cMCP::Principal" + ], + "resourceTypes": [ + "cMCP::Resource" + ], + "context": { + "type": "Record", + "attributes": { + "session_max_sensitivity": { + "type": "String", + "required": true + }, + "workflow_id": { + "type": "String", + "required": true + } + } + } + } + } + } + } +} diff --git a/demos/demo-02-policy-swap/policies-v2/deny-write.cedar b/demos/demo-02-policy-swap/policies-v2/deny-write.cedar new file mode 100644 index 00000000..2d5fa3e4 --- /dev/null +++ b/demos/demo-02-policy-swap/policies-v2/deny-write.cedar @@ -0,0 +1,18 @@ +// v2 policy: read_file allowed; write_file DENIED +// This is the "swapped" policy. Its bundle hash differs from v1. +// Any verifier that pinned the v1 hash will reject TRACE claims produced +// by a cMCP instance loaded with this bundle. +// +// AGT CedarBackend maps write_file -> Action::"WriteFile" + +permit ( + principal, + action == Action::"ReadFile", + resource +); + +forbid ( + principal, + action == Action::"WriteFile", + resource +); diff --git a/demos/demo-02-policy-swap/policies-v2/manifest.json b/demos/demo-02-policy-swap/policies-v2/manifest.json new file mode 100644 index 00000000..b27f4495 --- /dev/null +++ b/demos/demo-02-policy-swap/policies-v2/manifest.json @@ -0,0 +1,7 @@ +{ + "version": "1.0.0", + "authored_at": "2026-06-22T00:00:00Z", + "author_identity": "demo-setup", + "commit_sha": "demo-do-not-use-in-production", + "approval_chain": [] +} diff --git a/demos/demo-02-policy-swap/policies-v2/schema.cedarschema b/demos/demo-02-policy-swap/policies-v2/schema.cedarschema new file mode 100644 index 00000000..a3b37be6 --- /dev/null +++ b/demos/demo-02-policy-swap/policies-v2/schema.cedarschema @@ -0,0 +1,67 @@ +{ + "cMCP": { + "entityTypes": { + "Principal": { + "memberOfTypes": [], + "shape": { + "type": "Record", + "attributes": { + "session_id": { + "type": "String", + "required": true + }, + "workflow_id": { + "type": "String", + "required": true + } + } + } + }, + "Resource": { + "memberOfTypes": [], + "shape": { + "type": "Record", + "attributes": { + "tool_name": { + "type": "String", + "required": true + }, + "compliance_domain": { + "type": "String", + "required": true + }, + "baa_covered": { + "type": "Bool", + "required": true + } + } + } + } + }, + "actions": { + "call_tool": { + "appliesTo": { + "principalTypes": [ + "cMCP::Principal" + ], + "resourceTypes": [ + "cMCP::Resource" + ], + "context": { + "type": "Record", + "attributes": { + "session_max_sensitivity": { + "type": "String", + "required": true + }, + "workflow_id": { + "type": "String", + "required": true + } + } + } + } + } + } + } +} diff --git a/demos/demo-02-policy-swap/run.py b/demos/demo-02-policy-swap/run.py new file mode 100644 index 00000000..f2322db9 --- /dev/null +++ b/demos/demo-02-policy-swap/run.py @@ -0,0 +1,287 @@ +"""Demo 2: Policy swap = attestation failure — cross-platform launcher (replaces run.sh). + +Usage: + python demo-02-policy-swap/run.py # from repo root + python run.py # from demo-02 directory +""" +import json +import os +import pathlib +import shutil +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request + +sys.stdout.reconfigure(line_buffering=True) + +SCRIPT_DIR = pathlib.Path(__file__).parent.resolve() +REPO_ROOT = SCRIPT_DIR.parent +WORKSPACE = REPO_ROOT / "workspace" +CLAIM_PATH = WORKSPACE / "trace-claim.json" +V2_CLAIM_PATH = WORKSPACE / "trace-claim-v2.json" + + +def _find_cmcp() -> str: + found = shutil.which("cmcp") + if found: + return found + import sysconfig + candidates = [ + pathlib.Path(sys.executable).parent, + pathlib.Path(sysconfig.get_path("scripts")), + pathlib.Path(sysconfig.get_path("scripts", "nt_user")), + ] + for scripts in candidates: + for name in ("cmcp.exe", "cmcp"): + p = scripts / name + if p.exists(): + return str(p) + sys.exit("cmcp not found. Run: pip install cmcp-runtime") + + + +def _log_tail(what: str, lines: int = 15) -> str: + """Return the tail of the log for `what`, or "" if there is nothing to show. + + A service that fails to bind has already written the reason to its log. The + launcher used to point at the file and leave; printing the tail turns a 60 + second wait plus a scavenger hunt into the actual error. + """ + name = "cmcp.log" if "cMCP" in what or "cmcp" in what else "server.log" + path = pathlib.Path(__file__).parent.resolve() / name + try: + tail = path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:] + except OSError: + return "" + if not tail: + return "" + body = "\n".join(" " + line for line in tail) + return f"\n\nLast {len(tail)} lines of {name}:\n{body}" + +def _wait_for_port(port: int, what: str, process=None, timeout: float = 60.0) -> None: + """Block until something is listening on 127.0.0.1:port. + + A fixed sleep used to be enough; cMCP Runtime now does attestation and audit + setup before it binds, so the demo raced startup and failed on a refused + connection. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process is not None and process.poll() is not None: + sys.exit(f"{what} exited with code {process.returncode} before listening on :{port}." + + _log_tail(what)) + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + return + except OSError: + time.sleep(0.25) + sys.exit(f"{what} did not start listening on :{port} within {timeout:.0f}s." + + _log_tail(what)) + + +def _post(url: str, payload: dict, token: str) -> dict: + data = json.dumps(payload).encode() + req = urllib.request.Request( + url, data=data, + headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=10) as r: + return json.loads(r.read()) + except urllib.error.HTTPError as exc: + return json.loads(exc.read()) + + +def _assert_port_free(port: int, what: str) -> None: + """Refuse to start if something already owns the port. + + _wait_for_port() returns as soon as *anything* answers, so a gateway left + running by an earlier demo satisfies it instantly. Every call then goes to + that gateway and is decided by its policy bundle, not this demo's. The + verdicts still look plausible, which is what makes it dangerous: the demo + prints allow/deny lines that are simply wrong, with no error anywhere. + """ + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + pass + except OSError: + return + sys.exit( + f"Port {port} is already in use, so {what} cannot start and this demo " + f"would be scored against whatever is already listening. Stop it first " + f"(a cMCP gateway left over from another demo is the usual cause), then " + f"re-run." + ) + + +def _wait_for_port_release(port: int, timeout: float = 10.0) -> None: + """Block until the port is actually free, so the next demo starts clean.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + time.sleep(0.25) + except OSError: + return + print(f"warning: port {port} still held after teardown", file=sys.stderr) + + +def main() -> None: + # Before anything starts: both ports must be ours. + _assert_port_free(9001, "the MCP filesystem server") + _assert_port_free(8443, "the cMCP Runtime") + + if not CLAIM_PATH.exists(): + sys.exit("Run demo-01 first to produce a TRACE claim:\n python demo-01-cmcp-in-action/run.py") + + token = os.environ.setdefault("CMCP_BEARER_TOKEN", "demo-token") + + print() + print("=== Demo 2: Policy swap = attestation failure ===") + print() + + # Step 0: bundle hashes + print("-- Step 0: Policy bundle hashes --") + sys.stdout.flush() + subprocess.run([sys.executable, str(SCRIPT_DIR / "check_hash.py")], check=True) + + # Step 1: the approved (v1) bundle is the baseline a verifier pins. + # Compute it from the bundle itself so the value shown here is the same one + # printed in step 0 and pinned in step 5 -- reading it off the demo-01 claim + # instead showed a hash from a different bundle under the same "v1" label. + print() + print("-- Step 1: The approved policy bundle --") + v1_claim = json.loads(CLAIM_PATH.read_text()) + v1_policy_hash = subprocess.check_output( + [sys.executable, str(SCRIPT_DIR / "check_hash.py"), "v1"], text=True + ).split()[1] + catalog_hash = v1_claim["gateway"]["catalog"]["hash"] + print(f" v1 policy.bundle_hash: {v1_policy_hash}") + print(f" catalog.hash: {catalog_hash}") + print() + print(" A verifier who approved v1 pins the policy hash above.") + + log_dir = SCRIPT_DIR + server_log = open(log_dir / "server.log", "w") + cmcp_log = open(log_dir / "cmcp.log", "w") + + server = cmcp_proc = None + try: + # Step 2: start v2 runtime + print() + print("-- Step 2: Start cMCP with v2 policy (write_file DENIED) --") + server = subprocess.Popen( + [sys.executable, str(REPO_ROOT / "server" / "server.py")], + stdout=server_log, stderr=server_log, + ) + _wait_for_port(9001, "MCP filesystem server", server) + + env = os.environ.copy() + env["CMCP_DEV_MODE"] = "1" + cmcp_proc = subprocess.Popen( + [_find_cmcp(), "start", "--config", str(SCRIPT_DIR / "cmcp-config-v2.yaml")], + stdout=cmcp_log, stderr=cmcp_log, + cwd=SCRIPT_DIR, env=env, + ) + _wait_for_port(8443, "cMCP Runtime", cmcp_proc) + + # Step 3: write_file denied + print() + print("-- Step 3: write_file -> DENIED by v2 Cedar policy --") + body = _post("http://localhost:8443/mcp", { + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": { + "name": "write_file", + "arguments": {"path": "post-swap.txt", "content": "written after policy swap"}, + "_cmcp": {"workflow_id": "demo-02"}, + }, + }, token) + error = body.get("error", {}) + code = error.get("data", {}).get("error_code", "?") + print(f' HTTP 403 -- {error.get("message", "?")} [{code}]') + print(' Cedar forbid rule matched: Action::"WriteFile" denied by v2 policy') + + # Step 4: get v2 TRACE claim via read_file + print() + print("-- Step 4: Get v2 TRACE claim --") + r1 = _post("http://localhost:8443/mcp", { + "jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "read_file", "arguments": {"path": "hello.txt"}, "_cmcp": {"workflow_id": "demo-02"}}, + }, token) + session_id = r1["result"]["_cmcp"]["session_id"] + + close_req = urllib.request.Request( + f"http://localhost:8443/sessions/{session_id}/close", + data=b"{}", headers={"Content-Type": "application/json", "Authorization": f"Bearer {token}"}, + method="POST", + ) + with urllib.request.urlopen(close_req, timeout=10) as r2: + v2_claim = json.loads(r2.read()) + + v2_policy_hash = v2_claim["trace"]["policy"]["bundle_hash"] + print(f" v1 policy.bundle_hash: {v1_policy_hash}") + print(f" v2 policy.bundle_hash: {v2_policy_hash}") + print() + if v1_policy_hash != v2_policy_hash: + print(" Hashes differ. A verifier with the v1 approved hash will reject v2 claims.") + else: + print(" ERROR: hashes should differ but are identical.") + + V2_CLAIM_PATH.write_text(json.dumps(v2_claim, indent=2)) + print(" v2 claim saved.") + + finally: + for proc in (cmcp_proc, server): + if proc and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + _wait_for_port_release(8443) + _wait_for_port_release(9001) + server_log.close() + cmcp_log.close() + + # Steps 5-6: verify (servers now stopped) + v1_hash_out = subprocess.check_output( + [sys.executable, str(SCRIPT_DIR / "check_hash.py"), "v1"], text=True + ).split()[1] + v2_hash_out = subprocess.check_output( + [sys.executable, str(SCRIPT_DIR / "check_hash.py"), "v2"], text=True + ).split()[1] + cat_hash = json.loads(V2_CLAIM_PATH.read_text())["gateway"]["catalog"]["hash"] + cmcp = _find_cmcp() + + print() + print("-- Step 5: Verify v2 claim with v1 (pinned) hash -> POLICY_HASH_MISMATCH --") + print(" A verifier that approved v1 now rejects any claim from the v2 Runtime.") + print(" Watch the policy_bundle.hash line below: it FAILS.") + sys.stdout.flush() + subprocess.run( + [cmcp, "verify", str(V2_CLAIM_PATH), "--policy-hash", v1_hash_out, "--catalog-hash", cat_hash], + ) + + print() + print("-- Step 6: Verify v2 claim with v2 hash -> policy_bundle.hash now PASSES --") + print(" Same claim, correct pinned hash: the policy_bundle.hash line flips") + print(" FAIL -> PASS. The policy swap is fully accounted for.") + print(" The overall result stays partially_verified only because software-only") + print(" dev mode has no hardware_attestation to check. On real TDX it verifies.") + sys.stdout.flush() + subprocess.run( + [cmcp, "verify", str(V2_CLAIM_PATH), "--policy-hash", v2_hash_out, "--catalog-hash", cat_hash], + ) + + print() + print(" On real TDX hardware: RTMR[2] changes on policy swap.") + print(" No claim from the v2 Runtime can pass v1 verification.") + print() + + +if __name__ == "__main__": + main() diff --git a/demos/demo-02-policy-swap/run.sh b/demos/demo-02-policy-swap/run.sh new file mode 100644 index 00000000..e0b25e13 --- /dev/null +++ b/demos/demo-02-policy-swap/run.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Demo 2: policy swap +# +# Thin wrapper around run.py, which is the cross-platform launcher and the one +# CI exercises. This script used to start the servers itself with a fixed +# `sleep 2`, which raced cMCP startup and, worse, carried no check that the +# ports were free. Keeping the logic in one place means the shell path and the +# CI path cannot drift apart again. +# +# Usage: bash demo-02-policy-swap/run.sh (from repo root) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python "$SCRIPT_DIR/run.py" "$@" diff --git a/demos/demo-03-offline-trace/README.md b/demos/demo-03-offline-trace/README.md new file mode 100644 index 00000000..327872a0 --- /dev/null +++ b/demos/demo-03-offline-trace/README.md @@ -0,0 +1,28 @@ +# Demo 3: Offline TRACE verification + +**Duration:** ~60 seconds + +The signed TRACE claim from demo-01 is verified using only the claim itself and the hashes embedded in it. No running cMCP runtime, no server, no network call. This is the portability property of TRACE: verification is fully self-contained. + +**Requires demo-01 to have run first** (reads `../workspace/trace-claim.json`). + +## Run + +``` +python run.py +``` + +## What to show the audience + +1. `verify.py` loads only `workspace/trace-claim.json` and the hashes committed inside it +2. All cryptographic checks pass: schema, signature, policy hash, catalog hash, attestation freshness, audit chain +3. `hardware_attestation` lands in `unverified_fields`, so the status reads `partially_verified` + - This is expected under `CMCP_DEV_MODE=1` (software-only TEE, no hardware to attest) + - On real Intel TDX or AMD SEV-SNP, that field verifies too and the status becomes `verified` +4. Point out: `runtime.measurement` and `policy.bundle_hash` are committed in the Ed25519 signature + - Changing either field would break the signature + - No connection to the operator who produced the claim is needed + +## Key takeaway + +The TRACE claim is a portable proof. A regulator, auditor, or counterparty who holds the issuer's public key can verify any claim independently. The operator who produced it does not need to be online or trusted. Software-only mode proves the whole chain except the hardware root; hardware mode closes that last gap. diff --git a/demos/demo-03-offline-trace/run.py b/demos/demo-03-offline-trace/run.py new file mode 100644 index 00000000..656930a9 --- /dev/null +++ b/demos/demo-03-offline-trace/run.py @@ -0,0 +1,27 @@ +"""Demo 3: Offline TRACE verification — cross-platform launcher (replaces run.sh). + +Usage: + python demo-03-offline-trace/run.py # from repo root + python run.py # from demo-03 directory +""" +import pathlib +import subprocess +import sys + +sys.stdout.reconfigure(line_buffering=True) + +SCRIPT_DIR = pathlib.Path(__file__).parent.resolve() +REPO_ROOT = SCRIPT_DIR.parent +CLAIM_PATH = REPO_ROOT / "workspace" / "trace-claim.json" + + +def main() -> None: + if not CLAIM_PATH.exists(): + sys.exit("No claim found. Run demo-01 first:\n python demo-01-cmcp-in-action/run.py") + + sys.stdout.flush() + subprocess.run([sys.executable, str(SCRIPT_DIR / "verify.py")], check=True) + + +if __name__ == "__main__": + main() diff --git a/demos/demo-03-offline-trace/run.sh b/demos/demo-03-offline-trace/run.sh new file mode 100644 index 00000000..5a57879e --- /dev/null +++ b/demos/demo-03-offline-trace/run.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Demo 3: Offline TRACE verification +# +# Verifies the signed TRACE claim from demo-01 with no server connection. +# Only the claim file and the hashes embedded in it are used. +# +# Run demo-01 first: bash demo-01-cmcp-in-action/run.sh +# +# Usage: bash demo-03-offline-trace/run.sh (from repo root) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +if [[ ! -f "$REPO_ROOT/workspace/trace-claim.json" ]]; then + echo "No claim found. Run demo-01 first:" + echo " bash demo-01-cmcp-in-action/run.sh" + exit 1 +fi + +echo "" +echo "=== Demo 3: Offline TRACE verification ===" +echo "" +python "$SCRIPT_DIR/verify.py" diff --git a/demos/demo-03-offline-trace/verify.py b/demos/demo-03-offline-trace/verify.py new file mode 100644 index 00000000..73a9988e --- /dev/null +++ b/demos/demo-03-offline-trace/verify.py @@ -0,0 +1,104 @@ +""" +Demo 3: Offline TRACE claim verification + +Verifies the signed cMCP RuntimeClaim from demo-01 using ONLY: + - workspace/trace-claim.json (the claim) + - policy_bundle_hash and tool_catalog_hash from the claim itself + +No running cMCP Runtime. No server connection. No network call. + +The claim's own embedded hashes are used as the "approved" values because +demo-01 produced them under controlled conditions. In production, a verifier +pins these hashes independently of the claim producer. + +Usage: + python verify.py (from repo root, or via run.sh) +""" +import json +import pathlib +import sys + +CLAIM_PATH = pathlib.Path(__file__).parent.parent / "workspace" / "trace-claim.json" + + +def main(): + if not CLAIM_PATH.exists(): + print(f"Claim not found: {CLAIM_PATH}") + print("Run demo-01 first: bash demo-01-cmcp-in-action/run.sh") + sys.exit(1) + + claim = json.loads(CLAIM_PATH.read_text()) + + trace = claim.get("trace", {}) + gw = claim.get("gateway", {}) + policy_hash = trace.get("policy", {}).get("bundle_hash", "") + catalog_hash = gw.get("catalog", {}).get("hash", "") + + print("=== Demo 3: Offline TRACE verification ===") + print() + print(f"Claim: {CLAIM_PATH}") + print(f"Policy hash: {policy_hash}") + print(f"Catalog hash: {catalog_hash}") + print() + print("No network call. No gateway. Verifying Ed25519 signature and hashes...") + print() + + from cmcp_verify import ApprovedHashes, verify_trace_claim + + approved = ApprovedHashes( + policy_bundle_hash=policy_hash, + tool_catalog_hash=catalog_hash, + ) + result = verify_trace_claim(claim, approved) + + sep = "=" * 60 + print(sep) + print("VERIFICATION RESULT") + print(sep) + print(f" Status: {result.status.value}") + print(f" Verified fields: {', '.join(result.verified_fields)}") + print(f" Unverified: {', '.join(result.unverified_fields)}") + print(f" Failure reason: {result.failure_reason}") + print(f" Attestation age: {result.attestation_age_seconds}s") + print(f" Fresh: {result.is_attestation_fresh}") + if result.details: + print(" Details:") + for k, v in result.details.items(): + print(f" {k}: {v}") + print(sep) + print() + + dev_mode_only = ( + set(result.unverified_fields) == {"hardware_attestation"} + and not result.failure_reason + ) + + if result.status.value == "verified": + print("All cryptographic checks passed.") + print() + print("'hardware_attestation' is in unverified_fields because CMCP_DEV_MODE=1") + print("uses a software-only TEE. On real Intel TDX or AMD SEV-SNP, it would") + print("also be verified and status would remain 'verified' with full hardware") + print("provenance.") + elif result.status.value == "partially_verified" and dev_mode_only: + # Expected outcome under CMCP_DEV_MODE=1: every cryptographic check passes + # and only hardware_attestation is unverified because there is no real TEE. + print("All cryptographic checks passed.") + print() + print("Status is 'partially_verified' because CMCP_DEV_MODE=1 uses a software-only") + print("TEE, so 'hardware_attestation' cannot be verified. On real Intel TDX or AMD") + print("SEV-SNP, that field is verified too and the status becomes 'verified'.") + elif result.status.value == "partially_verified": + print(f"Partial verification. Failure: {result.failure_reason}") + sys.exit(1) + else: + print(f"Verification FAILED. Reason: {result.failure_reason}") + sys.exit(1) + + print() + print("No network call was made. No cMCP Runtime was contacted.") + print("Policy hash and measurement are committed in the Ed25519 signature.") + + +if __name__ == "__main__": + main() diff --git a/demos/demo-04-context-enforcement/README.md b/demos/demo-04-context-enforcement/README.md new file mode 100644 index 00000000..fe5fa109 --- /dev/null +++ b/demos/demo-04-context-enforcement/README.md @@ -0,0 +1,38 @@ +# Demo 4: context-aware enforcement + +**Duration:** ~90 seconds + +Demo 1 gates on the tool's identity: which tools may be called at all. This demo gates on the **context of the call**. The same capability is permitted inside one workflow and denied inside another. The agent cannot widen its own authority by restating its intent, because cMCP evaluates the declared workflow context, it does not trust the model. + +## Run + +```bash +python demo-04-context-enforcement/run.py # cross-platform +# or, bash only: +bash demo-04-context-enforcement/run.sh +``` + +## What to show the audience + +Three calls go through the gateway in one session: + +1. `write_file` under `workflow_id="invoice-run"` — **allowed**. The Cedar `permit` for `WriteFile` is guarded by `context.workflow_id == "invoice-run"`. +2. The **identical** `write_file`, same arguments, under `workflow_id="chat-freeform"` — **denied, HTTP 403**. No `permit` matches `WriteFile` outside `invoice-run`, so Cedar's default-deny holds. Same tool, same payload, different context, different decision. +3. `read_file` under `workflow_id="chat-freeform"` — **allowed**. Reads are permitted in any workflow, so the point is not that the second workflow is blocked wholesale; only the write capability is scoped. + +Then the session closes into a signed TRACE claim. Both the allow and the deny are committed to the hash-chained audit log under the same `policy.bundle_hash`, so you can prove which policy decided each call. + +## Why it matters + +"Trust the LLM to only write when it's supposed to" is not a control. Here the write authority is bound to a declared workflow and enforced before the call leaves the environment. A model that changes its stated intent does not change what it is allowed to do. + +## Policy + +`policies/workflow-scoped.cedar`: + +- `ReadFile` — permitted for any principal, any workflow. +- `WriteFile` — permitted only `when { context.workflow_id == "invoice-run" }`. + +The runtime maps `tool_name` to a PascalCase Cedar action (`write_file` → `Action::"WriteFile"`) and places call context (`workflow_id`, `compliance_domain`, `baa_covered`, `session_max_sensitivity`) on `context`. The agent supplies `workflow_id` per call via the `_cmcp` field; see `call.py`. + +Runs in software-only mode with `CMCP_DEV_MODE=1`; no hardware required. On real TDX the policy bundle hash flows into RTMR[2] at startup exactly as in demo 1. diff --git a/demos/demo-04-context-enforcement/call.py b/demos/demo-04-context-enforcement/call.py new file mode 100644 index 00000000..89f00964 --- /dev/null +++ b/demos/demo-04-context-enforcement/call.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Demo 4: context-aware enforcement. + +The same capability (write_file) is permitted inside the approved "invoice-run" +workflow and denied under any other workflow. The agent does not get to widen +its own authority by changing its stated intent: cMCP evaluates the declared +workflow context, it does not trust the model. + +Prerequisites: + server/server.py running on :9001 (plain HTTP JSON-RPC 2.0) + cmcp start --config cmcp-config.yaml running on :8443 + CMCP_BEARER_TOKEN env var set (run.py / run.sh export this) + +Usage: + python call.py (run from repo root, or via run.py) +""" +import json +import os +import pathlib +import sys +import time +import urllib.error +import urllib.request + +GATEWAY_URL = "http://localhost:8443" +TOKEN = os.environ.get("CMCP_BEARER_TOKEN", "demo-token") +WORKSPACE = pathlib.Path(__file__).parent.parent / "workspace" + +INVOICE_TEXT = "Invoice 4471 -- ACME Corp -- $12,400 -- net 30\n" + + +def _headers(): + return {"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}"} + + +def _post(url, payload): + data = json.dumps(payload).encode() + req = urllib.request.Request(url, data=data, headers=_headers(), method="POST") + try: + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read()), resp.status + except urllib.error.HTTPError as exc: + return json.loads(exc.read()), exc.code + + +def mcp_call(name, arguments, workflow_id): + return _post(f"{GATEWAY_URL}/mcp", { + "jsonrpc": "2.0", + "id": int(time.time() * 1000), + "method": "tools/call", + "params": { + "name": name, + "arguments": arguments, + "_cmcp": {"workflow_id": workflow_id}, + }, + }) + + +def main(): + WORKSPACE.mkdir(exist_ok=True) + session_id = None + + print("=== Demo 4: context-aware enforcement ===") + print("Approved tool: write_file. Approved workflow for writes: 'invoice-run'.\n") + + # 1. write_file inside the approved workflow -- Cedar: permit (workflow matches) + print("[1/3] write_file workflow='invoice-run' [approved workflow]") + body, status = mcp_call("write_file", {"path": "invoice.txt", "content": INVOICE_TEXT}, "invoice-run") + if status == 200: + cmcp = body["result"].get("_cmcp", {}) + session_id = cmcp.get("session_id") + print(f" ALLOWED {body['result']['content'][0]['text']!r}") + print(f" session_id={session_id}") + else: + print(f" ERROR {status}: {body}") + sys.exit(1) + + # 2. the IDENTICAL write, under a different workflow -- Cedar: default-deny (no permit) + print() + print("[2/3] write_file workflow='chat-freeform' [same tool, same args, other workflow]") + body, status = mcp_call("write_file", {"path": "invoice.txt", "content": INVOICE_TEXT}, "chat-freeform") + if status == 403: + error = body.get("error", {}) + code = error.get("data", {}).get("error_code", "?") + print(f" HTTP 403 -- {error.get('message', '?')} [{code}]") + print(" No permit matches WriteFile outside 'invoice-run'. Default-deny holds.") + print(" The model changed its stated intent; its authority did not change.") + else: + print(f" unexpected {status}: {body}") + sys.exit(1) + + # 3. read_file under the same 'chat-freeform' workflow -- Cedar: permit (reads allowed anywhere) + print() + print("[3/3] read_file workflow='chat-freeform' [reads permitted in any workflow]") + body, status = mcp_call("read_file", {"path": "invoice.txt"}, "chat-freeform") + if status == 200: + session_id = body["result"].get("_cmcp", {}).get("session_id") or session_id + print(f" ALLOWED content={body['result']['content'][0]['text'].strip()!r}") + print(" The workflow is not blanket-blocked. Only the write capability is scoped.") + else: + print(f" ERROR {status}: {body}") + + # close session -> signed TRACE claim + print() + print("Closing session -> TRACE claim...") + if not session_id: + print("ERROR: no session_id. Is cMCP returning _cmcp metadata?") + sys.exit(1) + + body, status = _post(f"{GATEWAY_URL}/sessions/{session_id}/close", {}) + if status != 200: + print(f"ERROR {status}: {body}") + sys.exit(1) + claim = body + + trace = claim.get("trace", {}) + gw = claim.get("gateway", {}) + chain = gw.get("audit_chain", {}) + + print() + print("=== TRACE claim ===") + print(f" runtime.platform: {trace.get('runtime', {}).get('platform')}") + print(f" policy.bundle_hash: {trace.get('policy', {}).get('bundle_hash')}") + print(f" policy.mode: {trace.get('policy', {}).get('enforcement_mode')}") + print(f" audit_chain.length: {chain.get('length')} (session lifecycle + every allow/deny, hash-chained)") + sig = claim.get("signature", "") + print(f" signature: {sig[:40]}...") + print() + print(" The allow AND the deny are both in the signed audit chain.") + print(" You can prove which policy was in force when each call was decided.") + + claim_path = WORKSPACE / "trace-claim-demo04.json" + claim_path.write_text(json.dumps(claim, indent=2)) + print() + print(f" Claim saved to {claim_path}") + + +if __name__ == "__main__": + main() diff --git a/demos/demo-04-context-enforcement/catalog.json b/demos/demo-04-context-enforcement/catalog.json new file mode 100644 index 00000000..7c41383d --- /dev/null +++ b/demos/demo-04-context-enforcement/catalog.json @@ -0,0 +1,66 @@ +[ + { + "tool_name": "write_file", + "server": { + "display_name": "Demo Filesystem MCP Server", + "url": "http://localhost:9001/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "rotation_mode": "key-pinned", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Write content to a file in the demo workspace", + "input_schema": { + "type": "object", + "required": [ + "path", + "content" + ], + "properties": { + "path": { + "type": "string" + }, + "content": { + "type": "string" + } + } + } + }, + "definition_hash": "sha256:a1e420dc7faa479147d132a69f55e12cfdb5f52e215f2bff78a8877924c8322b", + "compliance_domain": "finance", + "requires_baa": false, + "sensitivity_level": "confidential", + "added_at": "2026-07-20T00:00:00Z", + "approved_by": "demo-setup" + }, + { + "tool_name": "read_file", + "server": { + "display_name": "Demo Filesystem MCP Server", + "url": "http://localhost:9001/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "rotation_mode": "key-pinned", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Read a file from the demo workspace", + "input_schema": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + } + } + } + }, + "definition_hash": "sha256:b62b2bd456a551a8cc0f70dce71b06e358c73a8f16b8220c1b8fea7ae8b1a0e4", + "compliance_domain": "finance", + "requires_baa": false, + "sensitivity_level": "confidential", + "added_at": "2026-07-20T00:00:00Z", + "approved_by": "demo-setup" + } +] diff --git a/demos/demo-04-context-enforcement/cmcp-config.yaml b/demos/demo-04-context-enforcement/cmcp-config.yaml new file mode 100644 index 00000000..d627e434 --- /dev/null +++ b/demos/demo-04-context-enforcement/cmcp-config.yaml @@ -0,0 +1,16 @@ +attestation: + provider: auto # falls back to software-only when CMCP_DEV_MODE=1 + enforcement_mode: enforcing + validity_seconds: 3600 + +# 'finance' is not one of cMCP's built-in compliance domains. Declaring it here +# is what makes the catalog loadable, and regulated: true is what makes a call +# leaving it a recorded compliance boundary crossing in the TRACE claim. +sensitivity: + compliance_domains: + finance: true + +policy_bundle_path: ./policies/ +catalog_path: ./catalog.json +listen_addr: "127.0.0.1:8443" +audit_db_path: ./audit.db diff --git a/demos/demo-04-context-enforcement/policies/manifest.json b/demos/demo-04-context-enforcement/policies/manifest.json new file mode 100644 index 00000000..06e4de93 --- /dev/null +++ b/demos/demo-04-context-enforcement/policies/manifest.json @@ -0,0 +1,7 @@ +{ + "version": "1.0.0", + "authored_at": "2026-07-20T00:00:00Z", + "author_identity": "demo-setup", + "commit_sha": "demo-do-not-use-in-production", + "approval_chain": [] +} diff --git a/demos/demo-04-context-enforcement/policies/schema.cedarschema b/demos/demo-04-context-enforcement/policies/schema.cedarschema new file mode 100644 index 00000000..55e24a60 --- /dev/null +++ b/demos/demo-04-context-enforcement/policies/schema.cedarschema @@ -0,0 +1,75 @@ +{ + "cMCP": { + "entityTypes": { + "Principal": { + "memberOfTypes": [], + "shape": { + "type": "Record", + "attributes": { + "session_id": { + "type": "String", + "required": true + }, + "workflow_id": { + "type": "String", + "required": true + } + } + } + }, + "Resource": { + "memberOfTypes": [], + "shape": { + "type": "Record", + "attributes": { + "tool_name": { + "type": "String", + "required": true + }, + "compliance_domain": { + "type": "String", + "required": true + }, + "baa_covered": { + "type": "Bool", + "required": true + } + } + } + } + }, + "actions": { + "call_tool": { + "appliesTo": { + "principalTypes": [ + "cMCP::Principal" + ], + "resourceTypes": [ + "cMCP::Resource" + ], + "context": { + "type": "Record", + "attributes": { + "workflow_id": { + "type": "String", + "required": true + }, + "compliance_domain": { + "type": "String", + "required": true + }, + "baa_covered": { + "type": "Bool", + "required": true + }, + "session_max_sensitivity": { + "type": "String", + "required": true + } + } + } + } + } + } + } +} diff --git a/demos/demo-04-context-enforcement/policies/workflow-scoped.cedar b/demos/demo-04-context-enforcement/policies/workflow-scoped.cedar new file mode 100644 index 00000000..796d6a73 --- /dev/null +++ b/demos/demo-04-context-enforcement/policies/workflow-scoped.cedar @@ -0,0 +1,33 @@ +// cMCP Cedar policy for demo-04: context-aware enforcement +// +// demo-01 gates on the tool's identity (which tool may be called at all). +// This demo gates on the *context of the call*: the same capability is +// permitted inside one workflow and denied inside another. The agent cannot +// widen its own authority by choosing a different intent -- the policy checks +// the declared workflow, it does not trust the model. +// +// AGT's CedarBackend maps tool_name to a PascalCase action: +// read_file -> Action::"ReadFile" +// write_file -> Action::"WriteFile" +// +// The runtime places call context on `context` (workflow_id, compliance_domain, +// baa_covered, session_max_sensitivity). Cedar is default-deny: any call with no +// matching permit is refused. + +// Reads are permitted in any approved workflow. Low-risk, read-only. +permit ( + principal, + action == Action::"ReadFile", + resource +); + +// Writes are permitted ONLY inside the approved "invoice-run" workflow. +// The identical write attempted under any other workflow has no matching +// permit and is denied by default. Same tool, same arguments, different +// context -> different decision. +permit ( + principal, + action == Action::"WriteFile", + resource +) +when { context.workflow_id == "invoice-run" }; diff --git a/demos/demo-04-context-enforcement/run.py b/demos/demo-04-context-enforcement/run.py new file mode 100644 index 00000000..e3618307 --- /dev/null +++ b/demos/demo-04-context-enforcement/run.py @@ -0,0 +1,161 @@ +"""Demo 4: context-aware enforcement — cross-platform launcher (replaces run.sh). + +Usage: + python demo-04-context-enforcement/run.py # from repo root + python run.py # from demo-04 directory +""" +import os +import pathlib +import shutil +import socket +import subprocess +import sys +import time + +sys.stdout.reconfigure(line_buffering=True) + +SCRIPT_DIR = pathlib.Path(__file__).parent.resolve() +REPO_ROOT = SCRIPT_DIR.parent + + +def _find_cmcp() -> str: + found = shutil.which("cmcp") + if found: + return found + import sysconfig + candidates = [ + pathlib.Path(sys.executable).parent, + pathlib.Path(sysconfig.get_path("scripts")), + pathlib.Path(sysconfig.get_path("scripts", "nt_user")), + ] + for scripts in candidates: + for name in ("cmcp.exe", "cmcp"): + p = scripts / name + if p.exists(): + return str(p) + sys.exit("cmcp not found. Run: pip install cmcp-runtime") + + + +def _log_tail(what: str, lines: int = 15) -> str: + """Return the tail of the log for `what`, or "" if there is nothing to show. + + A service that fails to bind has already written the reason to its log. The + launcher used to point at the file and leave; printing the tail turns a 60 + second wait plus a scavenger hunt into the actual error. + """ + name = "cmcp.log" if "cMCP" in what or "cmcp" in what else "server.log" + path = pathlib.Path(__file__).parent.resolve() / name + try: + tail = path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:] + except OSError: + return "" + if not tail: + return "" + body = "\n".join(" " + line for line in tail) + return f"\n\nLast {len(tail)} lines of {name}:\n{body}" + +def _wait_for_port(port: int, what: str, process=None, timeout: float = 60.0) -> None: + """Block until something is listening on 127.0.0.1:port. + + A fixed sleep used to be enough; cMCP Runtime now does attestation and audit + setup before it binds, so the demo raced startup and failed on a refused + connection. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process is not None and process.poll() is not None: + sys.exit(f"{what} exited with code {process.returncode} before listening on :{port}." + + _log_tail(what)) + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + return + except OSError: + time.sleep(0.25) + sys.exit(f"{what} did not start listening on :{port} within {timeout:.0f}s." + + _log_tail(what)) + + +def _assert_port_free(port: int, what: str) -> None: + """Refuse to start if something already owns the port. + + _wait_for_port() returns as soon as *anything* answers, so a gateway left + running by an earlier demo satisfies it instantly. Every call then goes to + that gateway and is decided by its policy bundle, not this demo's. The + verdicts still look plausible, which is what makes it dangerous: the demo + prints allow/deny lines that are simply wrong, with no error anywhere. + """ + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + pass + except OSError: + return + sys.exit( + f"Port {port} is already in use, so {what} cannot start and this demo " + f"would be scored against whatever is already listening. Stop it first " + f"(a cMCP gateway left over from another demo is the usual cause), then " + f"re-run." + ) + + +def _wait_for_port_release(port: int, timeout: float = 10.0) -> None: + """Block until the port is actually free, so the next demo starts clean.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + time.sleep(0.25) + except OSError: + return + print(f"warning: port {port} still held after teardown", file=sys.stderr) + + +def main() -> None: + # Before anything starts: both ports must be ours. + _assert_port_free(9001, "the MCP filesystem server") + _assert_port_free(8443, "the cMCP Runtime") + + os.environ.setdefault("CMCP_BEARER_TOKEN", "demo-token") + + log_dir = SCRIPT_DIR + server_log = open(log_dir / "server.log", "w") + cmcp_log = open(log_dir / "cmcp.log", "w") + + server = cmcp_proc = None + try: + print("-- Starting MCP filesystem server on :9001 --", flush=True) + server = subprocess.Popen( + [sys.executable, str(REPO_ROOT / "server" / "server.py")], + stdout=server_log, stderr=server_log, + ) + _wait_for_port(9001, "MCP filesystem server", server) + + print("-- Starting cMCP Runtime (CMCP_DEV_MODE=1) on :8443 --", flush=True) + env = os.environ.copy() + env["CMCP_DEV_MODE"] = "1" + cmcp_proc = subprocess.Popen( + [_find_cmcp(), "start", "--config", str(SCRIPT_DIR / "cmcp-config.yaml")], + stdout=cmcp_log, stderr=cmcp_log, + cwd=SCRIPT_DIR, env=env, + ) + _wait_for_port(8443, "cMCP Runtime", cmcp_proc) + + print() + subprocess.run([sys.executable, str(SCRIPT_DIR / "call.py")], check=True) + + finally: + for proc in (cmcp_proc, server): + if proc and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + _wait_for_port_release(8443) + _wait_for_port_release(9001) + server_log.close() + cmcp_log.close() + + +if __name__ == "__main__": + main() diff --git a/demos/demo-04-context-enforcement/run.sh b/demos/demo-04-context-enforcement/run.sh new file mode 100644 index 00000000..fac9a2a8 --- /dev/null +++ b/demos/demo-04-context-enforcement/run.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Demo 4: context-aware enforcement +# +# Thin wrapper around run.py, which is the cross-platform launcher and the one +# CI exercises. This script used to start the servers itself with a fixed +# `sleep 2`, which raced cMCP startup and, worse, carried no check that the +# ports were free. Keeping the logic in one place means the shell path and the +# CI path cannot drift apart again. +# +# Usage: bash demo-04-context-enforcement/run.sh (from repo root) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python "$SCRIPT_DIR/run.py" "$@" diff --git a/demos/demo-05-compliance-domain/README.md b/demos/demo-05-compliance-domain/README.md new file mode 100644 index 00000000..565e63a3 --- /dev/null +++ b/demos/demo-05-compliance-domain/README.md @@ -0,0 +1,46 @@ +# Demo 5: attribute-based enforcement (compliance domain / BAA coverage) + +**Duration:** ~90 seconds + +Three ways to deny a tool call, one per demo: + +- **demo-01** denies by the tool's **identity** (which tool, by name). +- **demo-04** denies by the **call context** (which workflow). +- **demo-05** (this one) denies by the tool's **compliance attributes**: a tool that is not BAA-covered is refused by a single guardrail rule, whatever it is named. + +## Run + +```bash +python demo-05-compliance-domain/run.py # cross-platform +# or, bash only: +bash demo-05-compliance-domain/run.sh +``` + +## What to show the audience + +The catalog tags each tool with a compliance domain and BAA status: + +| tool | compliance_domain | baa_covered | +|------|-------------------|-------------| +| `write_file` | clinical | true | +| `read_file` | clinical | true | +| `list_dir` | external-analytics | false | + +Three calls in one session: + +1. `write_file` — **allowed** (`baa_covered=true`). +2. `read_file` — **allowed** (`baa_covered=true`). +3. `list_dir` — **denied, HTTP 403**. A `forbid` rule matches `context.baa_covered == false` and overrides the baseline permit. The call is refused on its attribute, not on its name. + +## Why it matters + +The guardrail is one rule: `forbid ... when { context.baa_covered == false }`. It covers every non-BAA-covered tool in the catalog, present and future. Add a tool tomorrow with `requires_baa: true` and it is refused on arrival, with no policy edit. That is the difference between allowlisting tool names one by one and enforcing a compliance property across the whole catalog. + +## Policy + +`policies/baa-guardrail.cedar`: + +- baseline `permit` for `ReadFile`, `WriteFile`, `ListDir`. +- `forbid ... when { context.baa_covered == false }` — a Cedar `forbid` overrides any `permit`, so the guardrail holds regardless of the tool name or the baseline rule. + +The runtime derives `context.compliance_domain` and `context.baa_covered` from the catalog entry (`baa_covered` is true when the entry does not set `requires_baa`). Runs software-only with `CMCP_DEV_MODE=1`; no hardware required. diff --git a/demos/demo-05-compliance-domain/call.py b/demos/demo-05-compliance-domain/call.py new file mode 100644 index 00000000..80a22520 --- /dev/null +++ b/demos/demo-05-compliance-domain/call.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Demo 5: attribute-based enforcement (compliance domain / BAA coverage). + +demo-01 denies by tool name. demo-04 denies by workflow context. This demo +denies by the tool's compliance attributes: a tool that is not BAA-covered is +refused by a single guardrail rule, whatever the tool is named. The decision is +made on `context.baa_covered`, derived by the runtime from the catalog. + +Prerequisites: + server/server.py running on :9001 (plain HTTP JSON-RPC 2.0) + cmcp start --config cmcp-config.yaml running on :8443 + CMCP_BEARER_TOKEN env var set (run.py / run.sh export this) + +Usage: + python call.py (run from repo root, or via run.py) +""" +import json +import os +import pathlib +import sys +import time +import urllib.error +import urllib.request + +GATEWAY_URL = "http://localhost:8443" +TOKEN = os.environ.get("CMCP_BEARER_TOKEN", "demo-token") +WORKSPACE = pathlib.Path(__file__).parent.parent / "workspace" + +CHART_TEXT = "Patient 88213 -- visit note -- BP 128/82 -- follow-up 2 weeks\n" + + +def _headers(): + return {"Content-Type": "application/json", "Authorization": f"Bearer {TOKEN}"} + + +def _post(url, payload): + data = json.dumps(payload).encode() + req = urllib.request.Request(url, data=data, headers=_headers(), method="POST") + try: + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read()), resp.status + except urllib.error.HTTPError as exc: + return json.loads(exc.read()), exc.code + + +def mcp_call(name, arguments): + return _post(f"{GATEWAY_URL}/mcp", { + "jsonrpc": "2.0", + "id": int(time.time() * 1000), + "method": "tools/call", + "params": { + "name": name, + "arguments": arguments, + "_cmcp": {"workflow_id": "clinical-intake"}, + }, + }) + + +def main(): + WORKSPACE.mkdir(exist_ok=True) + session_id = None + + print("=== Demo 5: attribute-based enforcement (BAA coverage) ===") + print("Guardrail: forbid any tool where context.baa_covered == false.\n") + print("Catalog:") + print(" write_file domain=clinical baa_covered=true") + print(" read_file domain=clinical baa_covered=true") + print(" list_dir domain=external-analytics baa_covered=false\n") + + # 1. write_file -- BAA-covered -> permitted + print("[1/3] write_file [clinical, baa_covered=true]") + body, status = mcp_call("write_file", {"path": "chart.txt", "content": CHART_TEXT}) + if status == 200: + session_id = body["result"].get("_cmcp", {}).get("session_id") + print(f" ALLOWED {body['result']['content'][0]['text']!r}") + else: + print(f" ERROR {status}: {body}") + sys.exit(1) + + # 2. read_file -- BAA-covered -> permitted + print() + print("[2/3] read_file [clinical, baa_covered=true]") + body, status = mcp_call("read_file", {"path": "chart.txt"}) + if status == 200: + session_id = body["result"].get("_cmcp", {}).get("session_id") or session_id + print(f" ALLOWED content={body['result']['content'][0]['text'].strip()!r}") + else: + print(f" ERROR {status}: {body}") + + # 3. list_dir -- NOT BAA-covered -> denied by the guardrail, not by its name + print() + print("[3/3] list_dir [external-analytics, baa_covered=false]") + body, status = mcp_call("list_dir", {}) + if status == 403: + error = body.get("error", {}) + code = error.get("data", {}).get("error_code", "?") + print(f" HTTP 403 -- {error.get('message', '?')} [{code}]") + print(" Denied by the BAA guardrail: context.baa_covered == false.") + print(" Not blocked by naming list_dir -- blocked by its compliance attribute.") + else: + print(f" unexpected {status}: {body}") + sys.exit(1) + + # close session -> signed TRACE claim + print() + print("Closing session -> TRACE claim...") + if not session_id: + print("ERROR: no session_id. Is cMCP returning _cmcp metadata?") + sys.exit(1) + + body, status = _post(f"{GATEWAY_URL}/sessions/{session_id}/close", {}) + if status != 200: + print(f"ERROR {status}: {body}") + sys.exit(1) + claim = body + + trace = claim.get("trace", {}) + gw = claim.get("gateway", {}) + chain = gw.get("audit_chain", {}) + + print() + print("=== TRACE claim ===") + print(f" runtime.platform: {trace.get('runtime', {}).get('platform')}") + print(f" policy.bundle_hash: {trace.get('policy', {}).get('bundle_hash')}") + print(f" policy.mode: {trace.get('policy', {}).get('enforcement_mode')}") + print(f" audit_chain.length: {chain.get('length')} (session lifecycle + every allow/deny, hash-chained)") + sig = claim.get("signature", "") + print(f" signature: {sig[:40]}...") + print() + print(" One guardrail rule covers every non-BAA-covered tool, current and future.") + print(" Add a tool to the catalog with requires_baa=true and it is refused on arrival.") + + claim_path = WORKSPACE / "trace-claim-demo05.json" + claim_path.write_text(json.dumps(claim, indent=2)) + print() + print(f" Claim saved to {claim_path}") + + +if __name__ == "__main__": + main() diff --git a/demos/demo-05-compliance-domain/catalog.json b/demos/demo-05-compliance-domain/catalog.json new file mode 100644 index 00000000..1f83e2c0 --- /dev/null +++ b/demos/demo-05-compliance-domain/catalog.json @@ -0,0 +1,94 @@ +[ + { + "tool_name": "write_file", + "server": { + "display_name": "Demo Filesystem MCP Server", + "url": "http://localhost:9001/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "rotation_mode": "key-pinned", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Write content to a file in the demo workspace", + "input_schema": { + "type": "object", + "required": [ + "path", + "content" + ], + "properties": { + "path": { + "type": "string" + }, + "content": { + "type": "string" + } + } + } + }, + "definition_hash": "sha256:a1e420dc7faa479147d132a69f55e12cfdb5f52e215f2bff78a8877924c8322b", + "compliance_domain": "clinical", + "requires_baa": false, + "sensitivity_level": "hipaa_phi", + "added_at": "2026-07-20T00:00:00Z", + "approved_by": "demo-setup" + }, + { + "tool_name": "read_file", + "server": { + "display_name": "Demo Filesystem MCP Server", + "url": "http://localhost:9001/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "rotation_mode": "key-pinned", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Read a file from the demo workspace", + "input_schema": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + } + } + } + }, + "definition_hash": "sha256:b62b2bd456a551a8cc0f70dce71b06e358c73a8f16b8220c1b8fea7ae8b1a0e4", + "compliance_domain": "clinical", + "requires_baa": false, + "sensitivity_level": "hipaa_phi", + "added_at": "2026-07-20T00:00:00Z", + "approved_by": "demo-setup" + }, + { + "tool_name": "list_dir", + "server": { + "display_name": "Demo Filesystem MCP Server", + "url": "http://localhost:9001/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "rotation_mode": "key-pinned", + "transport": "http-sse" + }, + "approved_definition": { + "description": "List files in the demo workspace", + "input_schema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "default": "" + } + } + } + }, + "definition_hash": "sha256:b3a1c4bd34d6f8fbb816d7c0ddad7997cdc1f319daba1fd5d1b0fff3821b04c5", + "compliance_domain": "external-analytics", + "requires_baa": true, + "sensitivity_level": "public", + "added_at": "2026-07-20T00:00:00Z", + "approved_by": "demo-setup" + } +] diff --git a/demos/demo-05-compliance-domain/cmcp-config.yaml b/demos/demo-05-compliance-domain/cmcp-config.yaml new file mode 100644 index 00000000..a0a276ce --- /dev/null +++ b/demos/demo-05-compliance-domain/cmcp-config.yaml @@ -0,0 +1,17 @@ +attestation: + provider: auto # falls back to software-only when CMCP_DEV_MODE=1 + enforcement_mode: enforcing + validity_seconds: 3600 + +# The point of this demo. Neither domain is built in to cMCP, and a deployment +# declares what its own classification means: leaving 'clinical' is a compliance +# boundary crossing, leaving 'external-analytics' is ordinary traffic. +sensitivity: + compliance_domains: + clinical: true + external-analytics: false + +policy_bundle_path: ./policies/ +catalog_path: ./catalog.json +listen_addr: "127.0.0.1:8443" +audit_db_path: ./audit.db diff --git a/demos/demo-05-compliance-domain/policies/baa-guardrail.cedar b/demos/demo-05-compliance-domain/policies/baa-guardrail.cedar new file mode 100644 index 00000000..9bc9be43 --- /dev/null +++ b/demos/demo-05-compliance-domain/policies/baa-guardrail.cedar @@ -0,0 +1,32 @@ +// cMCP Cedar policy for demo-05: attribute-based enforcement +// +// demo-01 gates on the tool's identity (which tool by name). +// demo-04 gates on the call context (which workflow). +// This demo gates on the tool's *compliance attributes*: a tool that is not +// BAA-covered is refused, whatever it is called and whoever calls it. +// +// The runtime derives these from the catalog and places them on `context`: +// context.compliance_domain <- catalog entry's compliance_domain +// context.baa_covered <- true when the catalog entry does NOT require a BAA +// +// AGT maps tool_name -> PascalCase action: +// write_file -> Action::"WriteFile" +// read_file -> Action::"ReadFile" +// list_dir -> Action::"ListDir" + +// Baseline: the approved catalog tools may be called. +permit ( + principal, + action in [Action::"WriteFile", Action::"ReadFile", Action::"ListDir"], + resource +); + +// Guardrail: never call a tool that is not BAA-covered. +// A Cedar forbid overrides any permit, so this holds regardless of the tool +// name or the permit above. The decision is made on the attribute, not the name. +forbid ( + principal, + action, + resource +) +when { context.baa_covered == false }; diff --git a/demos/demo-05-compliance-domain/policies/manifest.json b/demos/demo-05-compliance-domain/policies/manifest.json new file mode 100644 index 00000000..06e4de93 --- /dev/null +++ b/demos/demo-05-compliance-domain/policies/manifest.json @@ -0,0 +1,7 @@ +{ + "version": "1.0.0", + "authored_at": "2026-07-20T00:00:00Z", + "author_identity": "demo-setup", + "commit_sha": "demo-do-not-use-in-production", + "approval_chain": [] +} diff --git a/demos/demo-05-compliance-domain/policies/schema.cedarschema b/demos/demo-05-compliance-domain/policies/schema.cedarschema new file mode 100644 index 00000000..55e24a60 --- /dev/null +++ b/demos/demo-05-compliance-domain/policies/schema.cedarschema @@ -0,0 +1,75 @@ +{ + "cMCP": { + "entityTypes": { + "Principal": { + "memberOfTypes": [], + "shape": { + "type": "Record", + "attributes": { + "session_id": { + "type": "String", + "required": true + }, + "workflow_id": { + "type": "String", + "required": true + } + } + } + }, + "Resource": { + "memberOfTypes": [], + "shape": { + "type": "Record", + "attributes": { + "tool_name": { + "type": "String", + "required": true + }, + "compliance_domain": { + "type": "String", + "required": true + }, + "baa_covered": { + "type": "Bool", + "required": true + } + } + } + } + }, + "actions": { + "call_tool": { + "appliesTo": { + "principalTypes": [ + "cMCP::Principal" + ], + "resourceTypes": [ + "cMCP::Resource" + ], + "context": { + "type": "Record", + "attributes": { + "workflow_id": { + "type": "String", + "required": true + }, + "compliance_domain": { + "type": "String", + "required": true + }, + "baa_covered": { + "type": "Bool", + "required": true + }, + "session_max_sensitivity": { + "type": "String", + "required": true + } + } + } + } + } + } + } +} diff --git a/demos/demo-05-compliance-domain/run.py b/demos/demo-05-compliance-domain/run.py new file mode 100644 index 00000000..5b6fb5ab --- /dev/null +++ b/demos/demo-05-compliance-domain/run.py @@ -0,0 +1,161 @@ +"""Demo 5: attribute-based enforcement — cross-platform launcher (replaces run.sh). + +Usage: + python demo-05-compliance-domain/run.py # from repo root + python run.py # from demo-05 directory +""" +import os +import pathlib +import shutil +import socket +import subprocess +import sys +import time + +sys.stdout.reconfigure(line_buffering=True) + +SCRIPT_DIR = pathlib.Path(__file__).parent.resolve() +REPO_ROOT = SCRIPT_DIR.parent + + +def _find_cmcp() -> str: + found = shutil.which("cmcp") + if found: + return found + import sysconfig + candidates = [ + pathlib.Path(sys.executable).parent, + pathlib.Path(sysconfig.get_path("scripts")), + pathlib.Path(sysconfig.get_path("scripts", "nt_user")), + ] + for scripts in candidates: + for name in ("cmcp.exe", "cmcp"): + p = scripts / name + if p.exists(): + return str(p) + sys.exit("cmcp not found. Run: pip install cmcp-runtime") + + + +def _log_tail(what: str, lines: int = 15) -> str: + """Return the tail of the log for `what`, or "" if there is nothing to show. + + A service that fails to bind has already written the reason to its log. The + launcher used to point at the file and leave; printing the tail turns a 60 + second wait plus a scavenger hunt into the actual error. + """ + name = "cmcp.log" if "cMCP" in what or "cmcp" in what else "server.log" + path = pathlib.Path(__file__).parent.resolve() / name + try: + tail = path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:] + except OSError: + return "" + if not tail: + return "" + body = "\n".join(" " + line for line in tail) + return f"\n\nLast {len(tail)} lines of {name}:\n{body}" + +def _wait_for_port(port: int, what: str, process=None, timeout: float = 60.0) -> None: + """Block until something is listening on 127.0.0.1:port. + + A fixed sleep used to be enough; cMCP Runtime now does attestation and audit + setup before it binds, so the demo raced startup and failed on a refused + connection. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process is not None and process.poll() is not None: + sys.exit(f"{what} exited with code {process.returncode} before listening on :{port}." + + _log_tail(what)) + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + return + except OSError: + time.sleep(0.25) + sys.exit(f"{what} did not start listening on :{port} within {timeout:.0f}s." + + _log_tail(what)) + + +def _assert_port_free(port: int, what: str) -> None: + """Refuse to start if something already owns the port. + + _wait_for_port() returns as soon as *anything* answers, so a gateway left + running by an earlier demo satisfies it instantly. Every call then goes to + that gateway and is decided by its policy bundle, not this demo's. The + verdicts still look plausible, which is what makes it dangerous: the demo + prints allow/deny lines that are simply wrong, with no error anywhere. + """ + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + pass + except OSError: + return + sys.exit( + f"Port {port} is already in use, so {what} cannot start and this demo " + f"would be scored against whatever is already listening. Stop it first " + f"(a cMCP gateway left over from another demo is the usual cause), then " + f"re-run." + ) + + +def _wait_for_port_release(port: int, timeout: float = 10.0) -> None: + """Block until the port is actually free, so the next demo starts clean.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + time.sleep(0.25) + except OSError: + return + print(f"warning: port {port} still held after teardown", file=sys.stderr) + + +def main() -> None: + # Before anything starts: both ports must be ours. + _assert_port_free(9001, "the MCP filesystem server") + _assert_port_free(8443, "the cMCP Runtime") + + os.environ.setdefault("CMCP_BEARER_TOKEN", "demo-token") + + log_dir = SCRIPT_DIR + server_log = open(log_dir / "server.log", "w") + cmcp_log = open(log_dir / "cmcp.log", "w") + + server = cmcp_proc = None + try: + print("-- Starting MCP filesystem server on :9001 --", flush=True) + server = subprocess.Popen( + [sys.executable, str(REPO_ROOT / "server" / "server.py")], + stdout=server_log, stderr=server_log, + ) + _wait_for_port(9001, "MCP filesystem server", server) + + print("-- Starting cMCP Runtime (CMCP_DEV_MODE=1) on :8443 --", flush=True) + env = os.environ.copy() + env["CMCP_DEV_MODE"] = "1" + cmcp_proc = subprocess.Popen( + [_find_cmcp(), "start", "--config", str(SCRIPT_DIR / "cmcp-config.yaml")], + stdout=cmcp_log, stderr=cmcp_log, + cwd=SCRIPT_DIR, env=env, + ) + _wait_for_port(8443, "cMCP Runtime", cmcp_proc) + + print() + subprocess.run([sys.executable, str(SCRIPT_DIR / "call.py")], check=True) + + finally: + for proc in (cmcp_proc, server): + if proc and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + proc.kill() + _wait_for_port_release(8443) + _wait_for_port_release(9001) + server_log.close() + cmcp_log.close() + + +if __name__ == "__main__": + main() diff --git a/demos/demo-05-compliance-domain/run.sh b/demos/demo-05-compliance-domain/run.sh new file mode 100644 index 00000000..bb0ae2b7 --- /dev/null +++ b/demos/demo-05-compliance-domain/run.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Demo 5: attribute-based enforcement (BAA coverage) +# +# Thin wrapper around run.py, which is the cross-platform launcher and the one +# CI exercises. This script used to start the servers itself with a fixed +# `sleep 2`, which raced cMCP startup and, worse, carried no check that the +# ports were free. Keeping the logic in one place means the shell path and the +# CI path cannot drift apart again. +# +# Usage: bash demo-05-compliance-domain/run.sh (from repo root) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec python "$SCRIPT_DIR/run.py" "$@" diff --git a/demos/demo-06-weight-custody/run.py b/demos/demo-06-weight-custody/run.py new file mode 100644 index 00000000..96c3f3ca --- /dev/null +++ b/demos/demo-06-weight-custody/run.py @@ -0,0 +1,212 @@ +"""Demo 6: Weight custody - prove the model is the one the builder shipped. + +Usage: + python demo-06-weight-custody/run.py # from repo root + python run.py # from the demo-06 directory + +A pure in-process Weight Custody Manifest flow with a software (mock) +attestation provider, so it runs anywhere with no hardware. It shows the four +things a manifest gives you over a bare checkpoint download: provenance, an +attestation gate, tamper refusal, and derivative lineage. Possession is not +provenance. +""" +from __future__ import annotations + +import hashlib +import sys + +from wcm import ( + ReleasePolicy, + manifest_identity, + EnclaveSession, + Ed25519Signer, + KeyBrokerService, + SoftwareProvider, + VerificationContext, + WeightCustodyManifest, + generate_ed25519, + is_root, + verify_lineage, + verify_manifest, +) + +sys.stdout.reconfigure(line_buffering=True) + +#: From the release after 0.28.4, a manifest requires cryptographic evidence +#: verification unless it says otherwise, and GPU confidential-compute mode is +#: met only by a verified GPU report. This demo runs on mock attestation, which +#: nothing can verify, so its manifests waive both explicitly. On 0.28.4 the +#: field does not exist and the waivers are left out. +_REQUIRES_VERIFICATION = "require_evidence_verification" in ReleasePolicy.model_fields + + +def waive_mock_verification(doc: dict) -> dict: + if _REQUIRES_VERIFICATION: + policy = doc["release_policy"] + policy["require_evidence_verification"] = False + gpu = policy.get("required_gpu_measurement") + if gpu is not None: + gpu["require_cc_mode"] = False + return doc + + +def rule(title: str) -> None: + print(f"\n{'-' * 66}\n{title}\n{'-' * 66}") + + +def sha256(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def build_manifest( + *, + weights_hash: str, + license_text: str, + serving_measurement: str, + org: str, + derivatives: str, + derived_from: str | None = None, + rights_holder: dict | None = None, +) -> dict: + m: dict = { + "manifest_version": "0.1", + "weights_hash": weights_hash, + "builder": {"identity": org, "signing_key": "ed25519:demo"}, + "release_terms": { + "license": license_text, + "permitted_derivatives": "fine-tune-only", + "derivatives": derivatives, + "permitted_environments": ["enterprise-governed-enclave"], + }, + "release_policy": { + "required_assurance_tier": "hardware-attested", + "trusted_time_source": "secure-tsc", + "required_hw_platform": ["amd-sev-snp", "nvidia-cc-gpu"], + "required_gpu_measurement": {"rim_pin": "nvidia-rim:demo-golden"}, + "required_serving_image": { + "signer": "ed25519:demo", + "release_rule": "prefer-current", + "accepted_measurements": [ + {"measurement": serving_measurement, "status": "current"} + ], + }, + "attestation_revocation_check": "live-per-release, max-cache-age: short-window", + "revocation_authority": "builder-and-opaque-joint", + }, + "custody": { + "custodian": org, + "custodian_type": "customer-self-custody", + "kbs_image": {"measurement": sha256(b"reference-kbs-image"), "signer": "ed25519:demo"}, + "enclave_id": "did:example:enterprise-enclave-01", + "attestation_cadence": "1h", + }, + "base_confidentiality": "gated-open", + "deployment_model": "builder-to-customer", + } + if derived_from is not None: + m["derived_from"] = derived_from + if rights_holder is not None: + m["rights_holder"] = rights_holder + return m + + +def sign(manifest: WeightCustodyManifest, keypair, role: str, signer: str) -> dict: + return Ed25519Signer(keypair).sign(manifest.unsigned_dict(), role=role, signer=signer) + + +def _release(kbs: KeyBrokerService, manifest: WeightCustodyManifest, serving: str): + """Issue a fresh single-use challenge, produce evidence, run the gate.""" + challenge = kbs.issue_challenge() + evidence = SoftwareProvider().produce( + challenge, + serving_image_measurement=serving, + gpu_measurement="nvidia-rim:demo-golden", + ) + return kbs.verify_and_release(manifest, evidence) + + +def main() -> None: + print("Weight Custody Manifest: possession is not provenance.") + print("Real WCM code with a software (mock) attestation provider, no hardware.") + + builder = generate_ed25519() # the model builder + custodian = generate_ed25519() # the deploying customer / governance function + + # The builder ships a checkpoint and a serving stack it certifies. + checkpoint = b"" + base_hash = sha256(checkpoint) + serving = sha256(b"vllm-0.6.3 + policy-bundle-v2 (the certified serving stack)") + cadence = "1h" + + rule("1. The builder signs a manifest binding the exact weight hash") + base_doc = build_manifest( + weights_hash=base_hash, + license_text="Frontier-Model-License (usage + field-of-use)", + serving_measurement=serving, + org="frontier-labs", + derivatives="fine-tune-only", + ) + base = WeightCustodyManifest.model_validate(waive_mock_verification(base_doc)) + base = base.with_signatures([ + sign(base, builder, "builder", "frontier-labs"), + sign(base, custodian, "custodian", "enterprise-governance"), + ]) + ctx = VerificationContext() + ctx.add_key(builder.public_bytes) + ctx.add_key(custodian.public_bytes) + print("weights_hash bound :", base_hash) + print("manifest signature :", verify_manifest(base, ctx).ok, "(jointly signed builder + custodian)") + + rule("2. Attestation gate: the key releases only into the certified stack") + # The KBS pins the exact manifest identity it will release for. Without + # this the gate refuses, and correctly: a caller-supplied manifest is not + # its own trust root, so an unpinned one could name any weights it liked. + kbs = KeyBrokerService( + {base.weights_hash: b"the-model-decryption-key"}, + trusted_manifest_identities=[manifest_identity(base)], + ) + print("gate released key :", _release(kbs, base, serving).released) + print("enforced: genuine attestation nonce, approved platform, and a serving") + print("image measurement matching what the builder signed.") + + rule("3. A tampered checkpoint fails before it ever loads") + tampered = checkpoint.replace(b"certified", b"backdoored") + matches = sha256(tampered) == base.weights_hash + print("certified hash :", base.weights_hash) + print("downloaded hash :", sha256(tampered)) + print("matches manifest :", matches, "-> load proceeds" if matches else "-> REFUSE to load") + print("no human reads 2.8T parameters; the hash does the reading.") + + rule("4. The fine-tune is the real IP: lineage back to the signed base") + derivative = checkpoint + b"<+ proprietary fine-tune on private data>" + deriv_hash = sha256(derivative) + deriv_doc = build_manifest( + weights_hash=deriv_hash, + license_text="Frontier-Model-License + enterprise-proprietary-derivative", + serving_measurement=serving, + org="enterprise-governance", + derivatives="none", + derived_from=base.weights_hash, + rights_holder={"base": "frontier-labs", "derivative": "enterprise"}, + ) + deriv = WeightCustodyManifest.model_validate(waive_mock_verification(deriv_doc)) + deriv = deriv.with_signatures([ + sign(deriv, builder, "builder", "enterprise-governance"), + sign(deriv, custodian, "custodian", "enterprise-governance"), + ]) + lineage = verify_lineage({base.weights_hash: base, deriv.weights_hash: deriv}, deriv.weights_hash) + print("derivative :", deriv_hash) + print("lineage verified :", lineage.ok, " depth", lineage.depth, " root is a base:", is_root(base)) + + rule("What this is, and is not") + session = EnclaveSession.from_release(base, _release(kbs, base, serving)) + session.use_key() + print("custody active : key held under a", cadence, "cadence, wiped on lapse", + "(time_floor " + session.time_floor.value + ")") + print("honest scope : accountability-grade against an operator who physically") + print(" owns the silicon (see TEE.fail), not silicon-proof") + print(" custody. It IS the provenance the download never gave you.") + + +if __name__ == "__main__": + main() diff --git a/demos/demo-07-closed-weight/run.py b/demos/demo-07-closed-weight/run.py new file mode 100644 index 00000000..1c9c7424 --- /dev/null +++ b/demos/demo-07-closed-weight/run.py @@ -0,0 +1,145 @@ +"""Demo 7: Closed-weight custody - when secrecy IS the job. + +Usage: + python demo-07-closed-weight/run.py # from repo root + python run.py # from the demo-07 directory + +The counterpart to demo 6. Demo 6 ran an OPEN-weight model, where the base +weights are public so the machinery does integrity, license, and derivative +custody. Here the model is CLOSED (a frontier lab shipping weights into a +customer's or sovereign's own enclave), so the base weights ARE the secret and +the whole job is to keep the decryption key off the operator. Same protocol, +software (mock) attestation, no hardware. +""" +from __future__ import annotations + +import hashlib +import sys + +from wcm import ( + manifest_identity, + Ed25519Signer, + KeyBrokerService, + SoftwareProvider, + VerificationContext, + WeightCustodyManifest, + generate_ed25519, + verify_manifest, +) + +sys.stdout.reconfigure(line_buffering=True) + + +def rule(title: str) -> None: + print(f"\n{'-' * 66}\n{title}\n{'-' * 66}") + + +def sha256(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def build_manifest(*, weights_hash: str, serving: str, builder_id: str, custodian_id: str) -> dict: + return { + "manifest_version": "0.1", + "weights_hash": weights_hash, + "builder": {"identity": builder_id, "signing_key": "ed25519:demo"}, + "release_terms": { + "license": "Frontier-Model-License (confidential, field-of-use bound)", + "permitted_derivatives": "none", + "derivatives": "none", + "permitted_environments": ["sovereign-governed-enclave"], + }, + "release_policy": { + "required_assurance_tier": "hardware-attested", + "trusted_time_source": "secure-tsc", + "required_hw_platform": ["amd-sev-snp", "nvidia-cc-gpu"], + "required_gpu_measurement": {"rim_pin": "nvidia-rim:demo-golden"}, + "required_serving_image": { + "signer": "ed25519:demo", + "release_rule": "prefer-current", + "accepted_measurements": [{"measurement": serving, "status": "current"}], + }, + "attestation_revocation_check": "live-per-release, max-cache-age: short-window", + "revocation_authority": "builder-and-opaque-joint", + }, + "custody": { + "custodian": custodian_id, + "custodian_type": "customer-self-custody", + "kbs_image": {"measurement": sha256(b"reference-kbs-image"), "signer": "ed25519:demo"}, + "enclave_id": "did:example:sovereign-enclave-01", + "attestation_cadence": "1h", + }, + "base_confidentiality": "confidential", + "deployment_model": "builder-to-customer", + } + + +def sign(manifest: WeightCustodyManifest, keypair, role: str, signer: str) -> dict: + return Ed25519Signer(keypair).sign(manifest.unsigned_dict(), role=role, signer=signer) + + +def _evidence(kbs: KeyBrokerService, serving: str): + challenge = kbs.issue_challenge() + return SoftwareProvider().produce( + challenge, serving_image_measurement=serving, gpu_measurement="nvidia-rim:demo-golden" + ) + + +def main() -> None: + print("Closed-weight custody: the weights are the secret. Keep the key off the operator.") + print("Real WCM code with a software (mock) attestation provider, no hardware.") + + builder = generate_ed25519() # the frontier lab + custodian = generate_ed25519() # the deploying customer / sovereign + + weights_hash = sha256(b"") + serving = sha256(b"trusted-serving-stack + no-raw-weight-export-path") + + rule("1. The lab ships CLOSED weights and signs the release policy") + doc = build_manifest( + weights_hash=weights_hash, serving=serving, + builder_id="frontier-labs", custodian_id="sovereign-customer", + ) + manifest = WeightCustodyManifest.model_validate(doc) + manifest = manifest.with_signatures([ + sign(manifest, builder, "builder", "frontier-labs"), + sign(manifest, custodian, "custodian", "sovereign-customer"), + ]) + ctx = VerificationContext() + ctx.add_key(builder.public_bytes) + ctx.add_key(custodian.public_bytes) + result = verify_manifest(manifest, ctx) + print("base_confidentiality:", manifest.base_confidentiality.value, "(the weights are secret)") + print("manifest signature :", result.ok, "(jointly signed lab + customer)") + + rule("2. The key releases ONLY into the attested, lab-signed enclave") + # The KBS pins the exact manifest identity it will release for. Without + # this the gate refuses, and correctly: a caller-supplied manifest is not + # its own trust root, so an unpinned one could name any weights it liked. + kbs = KeyBrokerService( + {weights_hash: b"the-model-decryption-key"}, + trusted_manifest_identities=[manifest_identity(manifest)], + ) + released = kbs.verify_and_release(manifest, _evidence(kbs, serving)).released + print("approved enclave : key released =", released) + print("-> the weights decrypt only inside the measured stack, never on the host.") + + rule("3. An unapproved serving stack gets nothing") + tampered_serving = sha256(b"a modified serving stack with a raw-weight export path") + denied = kbs.verify_and_release(manifest, _evidence(kbs, tampered_serving)) + print("unapproved enclave : key released =", denied.released) + print("why :", next(c.detail for c in denied.failures)) + print("-> a stack that could exfiltrate plaintext weights never receives the key.") + + rule("Closed vs open: same protocol, different job") + print("closed model (here): the base weights ARE the secret; the job is secrecy.") + print("open model (demo 6): the base is public; the same steps do integrity,") + print(" license, and derivative custody instead.") + print("honest scope : cost, detection, and mandatory physical hardening") + print(" against a hardware owner who can lift keys (TEE.fail),") + print(" not silicon-proof custody. That is why the sovereign") + print(" profile adds a threshold split (demo 9).") + + +if __name__ == "__main__": + main() diff --git a/demos/demo-08-derivative-lineage/run.py b/demos/demo-08-derivative-lineage/run.py new file mode 100644 index 00000000..2f1c8810 --- /dev/null +++ b/demos/demo-08-derivative-lineage/run.py @@ -0,0 +1,146 @@ +"""Demo 8: Derivative lineage - the fine-tune is the real IP. + +Usage: + python demo-08-derivative-lineage/run.py # from repo root + python run.py # from the demo-08 directory + +When a customer fine-tunes a model inside the enclave on its own private data, +the result is novel IP that never existed publicly. WCM gives that derivative its +own signed manifest with a `derived_from` pointer and a `rights_holder` split, so +its whole chain of custody resolves back to the base, and the base's rules travel +down the chain. Software (mock) attestation, no hardware. +""" +from __future__ import annotations + +import hashlib +import sys + +from wcm import ( + Ed25519Signer, + WeightCustodyManifest, + generate_ed25519, + is_root, + verify_lineage, +) + +sys.stdout.reconfigure(line_buffering=True) + + +def rule(title: str) -> None: + print(f"\n{'-' * 66}\n{title}\n{'-' * 66}") + + +def sha256(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def build_manifest( + *, + weights_hash: str, + org: str, + derivatives: str, + derived_from: str | None = None, + rights_holder: dict | None = None, +) -> dict: + m: dict = { + "manifest_version": "0.1", + "weights_hash": weights_hash, + "builder": {"identity": org, "signing_key": "ed25519:demo"}, + "release_terms": { + "license": "Frontier-Model-License", + "permitted_derivatives": "fine-tune-only", + "derivatives": derivatives, + "permitted_environments": ["enterprise-governed-enclave"], + }, + "release_policy": { + "required_assurance_tier": "hardware-attested", + "trusted_time_source": "secure-tsc", + "required_hw_platform": ["amd-sev-snp", "nvidia-cc-gpu"], + "required_gpu_measurement": {"rim_pin": "nvidia-rim:demo-golden"}, + "required_serving_image": { + "signer": "ed25519:demo", + "release_rule": "prefer-current", + "accepted_measurements": [{"measurement": sha256(b"serving"), "status": "current"}], + }, + "attestation_revocation_check": "live-per-release, max-cache-age: short-window", + "revocation_authority": "builder-and-opaque-joint", + }, + "custody": { + "custodian": org, + "custodian_type": "customer-self-custody", + "kbs_image": {"measurement": sha256(b"reference-kbs-image"), "signer": "ed25519:demo"}, + "enclave_id": "did:example:enterprise-enclave-01", + "attestation_cadence": "1h", + }, + "base_confidentiality": "gated-open", + "deployment_model": "builder-to-customer", + } + if derived_from is not None: + m["derived_from"] = derived_from + if rights_holder is not None: + m["rights_holder"] = rights_holder + return m + + +def sign_manifest(doc: dict, keys: list) -> WeightCustodyManifest: + m = WeightCustodyManifest.model_validate(doc) + return m.with_signatures([ + Ed25519Signer(k).sign(m.unsigned_dict(), role=r, signer=s) for k, r, s in keys + ]) + + +def main() -> None: + print("Derivative lineage: the fine-tune is the asset. Prove where it came from.") + print("Real WCM code with a software (mock) attestation provider, no hardware.") + + builder, custodian = generate_ed25519(), generate_ed25519() + keys = [(builder, "builder", "acme-governance"), (custodian, "custodian", "acme-governance")] + + rule("1. The base model, signed") + base = sign_manifest( + build_manifest(weights_hash=sha256(b""), org="acme-governance", + derivatives="fine-tune-only"), + keys, + ) + print("base weights_hash :", base.weights_hash) + print("permits : fine-tune-only (derivatives allowed, one level)") + + rule("2. Fine-tune on private data: the derivative gets its own manifest") + deriv = sign_manifest( + build_manifest( + weights_hash=sha256(b"+"), + org="acme-governance", derivatives="none", + derived_from=base.weights_hash, + rights_holder={"base": "meta", "derivative": "acme"}, + ), + keys, + ) + print("derivative hash :", deriv.weights_hash) + print("derived_from :", deriv.derived_from) + print("rights_holder :", {"base": deriv.rights_holder.base, "derivative": deriv.rights_holder.derivative}) + print("permits : none (acme does not permit derivatives OF its derivative)") + + rule("3. Lineage resolves the derivative back to the base") + manifests = {base.weights_hash: base, deriv.weights_hash: deriv} + lineage = verify_lineage(manifests, deriv.weights_hash) + print("lineage ok :", lineage.ok) + print("chain (leaf->root) :", " -> ".join(h.split(":")[1][:12] + "..." for h in lineage.chain)) + print("depth :", lineage.depth, " base is a root:", is_root(base)) + print("-> this fine-tune never existed publicly. The chain is its custody record.") + + rule("4. The base's rules travel down the chain") + # Someone tries to fork the derivative, but the derivative permits no derivatives. + rogue = sign_manifest( + build_manifest(weights_hash=sha256(b""), org="rogue", + derivatives="none", derived_from=deriv.weights_hash), + keys, + ) + manifests[rogue.weights_hash] = rogue + bad = verify_lineage(manifests, rogue.weights_hash) + print("fork of derivative : lineage ok =", bad.ok) + print("violation :", bad.violations[0] if bad.violations else "(none)") + print("-> monotone rights: a child may narrow the parent's terms, never widen them.") + + +if __name__ == "__main__": + main() diff --git a/demos/demo-09-sovereign-threshold/run.py b/demos/demo-09-sovereign-threshold/run.py new file mode 100644 index 00000000..41ba8dde --- /dev/null +++ b/demos/demo-09-sovereign-threshold/run.py @@ -0,0 +1,150 @@ +"""Demo 9: Sovereign self-custody - no single party holds the key. + +Usage: + python demo-09-sovereign-threshold/run.py # from repo root + python run.py # from the demo-09 directory + +For a sovereign deployment the honest limit from demos 6 to 8 bites hardest: a +hardware owner who can forge one attestation could release a key. The answer is +to never let one release be enough. The model key is split 2-of-3 across +independent parties, each releasing its share only against its own valid +attestation, so a quorum of independent attestations is required and one forged +quote is below threshold. Software (mock) attestation, no hardware. +""" +from __future__ import annotations + +import hashlib +import sys + +from wcm import ( + manifest_identity, + Ed25519Signer, + KeyBrokerService, + Share, + SoftwareProvider, + WeightCustodyManifest, + combine_shares, + generate_ed25519, + split_secret, +) + +sys.stdout.reconfigure(line_buffering=True) + + +def rule(title: str) -> None: + print(f"\n{'-' * 66}\n{title}\n{'-' * 66}") + + +def sha256(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def pack(share: Share) -> bytes: + """Serialize a share so an independent KBS can hold and release it.""" + return bytes([share.x]) + share.y + + +def unpack(blob: bytes) -> Share: + return Share(x=blob[0], y=blob[1:]) + + +SERVING = sha256(b"sovereign-serving-stack") + + +def build_manifest(weights_hash: str) -> dict: + return { + "manifest_version": "0.1", + "weights_hash": weights_hash, + "builder": {"identity": "frontier-labs", "signing_key": "ed25519:demo"}, + "release_terms": { + "license": "Frontier-Model-License", + "permitted_derivatives": "none", + "derivatives": "none", + "permitted_environments": ["sovereign-governed-enclave"], + }, + "release_policy": { + "required_assurance_tier": "hardware-attested", + "trusted_time_source": "secure-tsc", + "required_hw_platform": ["amd-sev-snp", "nvidia-cc-gpu"], + "required_gpu_measurement": {"rim_pin": "nvidia-rim:demo-golden"}, + "required_serving_image": { + "signer": "ed25519:demo", + "release_rule": "prefer-current", + "accepted_measurements": [{"measurement": SERVING, "status": "current"}], + }, + "attestation_revocation_check": "live-per-release, max-cache-age: short-window", + "revocation_authority": "quorum", + }, + "custody": { + "custodian": "sovereign-customer", + "custodian_type": "customer-self-custody", + "kbs_image": {"measurement": sha256(b"reference-kbs-image"), "signer": "ed25519:demo"}, + "enclave_id": "did:example:sovereign-enclave-01", + "attestation_cadence": "1h", + }, + "base_confidentiality": "confidential", + "deployment_model": "builder-to-customer", + } + + +def attested_share(kbs: KeyBrokerService, manifest: WeightCustodyManifest) -> Share: + """Release one share from an independent shareholder's KBS against attestation.""" + challenge = kbs.issue_challenge() + evidence = SoftwareProvider().produce( + challenge, serving_image_measurement=SERVING, gpu_measurement="nvidia-rim:demo-golden" + ) + decision = kbs.verify_and_release(manifest, evidence) + return unpack(decision.key) + + +def main() -> None: + print("Sovereign self-custody: split the key so no single release is ever enough.") + print("Real WCM code with a software (mock) attestation provider, no hardware.") + + builder, custodian = generate_ed25519(), generate_ed25519() + model_key = b"the-sovereign-model-decryption-k" # 32 bytes + + rule("1. Split the model key 2-of-3 across independent parties") + shares = split_secret(model_key, threshold=2, shares=3) + holders = ["frontier-labs", "sovereign-authority", "sovereign-customer"] + for name, s in zip(holders, shares): + print(f" share {s.x} -> {name}") + print("policy: any 2 of the 3 reconstruct; no 1 can.") + + rule("2. No single party can reconstruct the key") + one = combine_shares([shares[0]]) + print("one share reconstructs the key:", one == model_key) + print("-> a lone shareholder, or a single forged attestation, gets nothing usable.") + + rule("3. Each shareholder releases its share only against attestation") + doc = build_manifest(sha256(b"")) + manifest = WeightCustodyManifest.model_validate(doc) + manifest = manifest.with_signatures([ + Ed25519Signer(builder).sign(manifest.unsigned_dict(), role="builder", signer="frontier-labs"), + Ed25519Signer(custodian).sign(manifest.unsigned_dict(), role="custodian", signer="sovereign-customer"), + ]) + # Each shareholder runs its own KBS holding only its share. + # The KBS pins the exact manifest identity it will release for. Without + # this the gate refuses, and correctly: a caller-supplied manifest is not + # its own trust root, so an unpinned one could name any weights it liked. + kbs_by_holder = [ + KeyBrokerService( + {manifest.weights_hash: pack(s)}, + trusted_manifest_identities=[manifest_identity(manifest)], + ) + for s in shares + ] + # A quorum of two independent parties each attest and release their share. + quorum = [attested_share(kbs_by_holder[0], manifest), attested_share(kbs_by_holder[2], manifest)] + print("attested releases :", len(quorum), "of 3 shareholders (a quorum)") + + rule("4. The quorum reconstructs; a single forged root does not") + recovered = combine_shares(quorum) + print("quorum reconstructs the key :", recovered == model_key) + print("-> to forge the key an attacker must forge attestation to a QUORUM of") + print(" independent sovereign-run roots, not one. That is why threshold is a") + print(" prerequisite for sovereign self-custody, not optional hardening.") + + +if __name__ == "__main__": + main() diff --git a/demos/demo-10-model-gateway/.gitignore b/demos/demo-10-model-gateway/.gitignore new file mode 100644 index 00000000..c05c40f7 --- /dev/null +++ b/demos/demo-10-model-gateway/.gitignore @@ -0,0 +1,5 @@ +audit*.db +audit*.db-shm +audit*.db-wal +*.log +trace-claim.json diff --git a/demos/demo-10-model-gateway/README.md b/demos/demo-10-model-gateway/README.md new file mode 100644 index 00000000..c20b5ad9 --- /dev/null +++ b/demos/demo-10-model-gateway/README.md @@ -0,0 +1,90 @@ +# Demo 10 — Governed model calls + +An OpenAI-compatible endpoint in front of cMCP. The caller changes one line — +`base_url` — and from then on every model call is checked against policy before it +leaves, identifiers are stripped at the boundary, and the session closes into a signed +TRACE claim anyone can verify offline. + +The other demos govern **tool** calls. This one governs **model** calls, which is a +different surface with a different question: not "may this agent use that tool", but +"may this class of data reach that model, in that region, on that infrastructure". + +``` +caller's OpenAI client + │ base_url = http://127.0.0.1:8500/v1 ← the only change + ▼ + endpoint.py OpenAI surface, redaction at the boundary + │ + ▼ + cMCP gateway :8444 Cedar decides, audit chain records, TRACE claim signed + │ + ▼ + model_server.py the inference call +``` + +## Run it + +``` +python demo-10-model-gateway/run.py +``` + +Ports: model backend `:9010`, gateway `:8444`, endpoint `:8500`. `CMCP_DEV_MODE=1` is +set for you, so no hardware is required, and no API key or network is needed. + +## What you see + +Five calls, at four sensitivity classes from cMCP's own vocabulary, against models in +two regions and two tenancies: + +| # | Model | Placement | Class | Outcome | +|---|---|---|---|---| +| 1 | `frontier-large` | out of region, shared | `public` | **allow** | +| 2 | `frontier-large` | out of region, shared | `pii`, prompt carries identifiers | **allow**, after `NATIONAL_ID` and `ACCOUNT_NO` are redacted | +| 3 | `frontier-large` | out of region, shared | `confidential` | **deny** — `data-residency` | +| 4 | `regional-small` | in region, **shared** | `hipaa_phi` | **deny** — `phi-isolation` | +| 5 | `regional-isolated` | in region, **dedicated** | `hipaa_phi` | **allow** | + +Call 2 is the one that changes what is possible: a model the caller could not use with +that data becomes usable, because the identifiers never leave and the call is provable. + +Calls 4 and 5 are the argument. Same prompt, same classification, both in region. +Refused on shared infrastructure, allowed on dedicated. **Proof makes an out-of-region +call provable, not permissible** — a residency rule still binds at `confidential`, and at +`hipaa_phi` the jurisdiction is necessary but not sufficient. + +Then the session closes into a signed claim, and: + +``` +cmcp verify demo-10-model-gateway/trace-claim.json +``` + +verifies it with no network, no gateway and no trust in whoever ran it. For the tamper +case — edit the policy, watch the pinned hash stop matching — see +[demo 2](../demo-02-policy-swap/). + +## What is real, and what stands in + +**Real** — the cMCP gateway, Cedar evaluation and the deny with its advice payload, the +hash-chained audit log, the signed TRACE claim, offline verification, and the boundary +redaction: `endpoint.py` genuinely strips the identifier before the prompt is forwarded. +The OpenAI request and response shapes are the real thing, so a stock SDK works. + +**Stands in** — the inference. `model_server.py` synthesises the completion so the demo +needs no key and no network. Set `MODEL_API_KEY` (and optionally `MODEL_BASE_URL`) and it +forwards to a real provider instead; nothing else in the path changes. + +**Demonstrative rather than production** — three redaction expressions, not a DLP engine. + +**Where the classification comes from** — `data_class` arrives as a request header, +standing in for a routing layer's own decision. That division matters: a caller must +never be able to declare its own data down a tier, so in a real deployment the header is +replaced by the router's verdict. + +**One thing the claim does not surface yet** — the transcript's per-entry `data_class` +reports the catalogued sensitivity of the tool, not the class of the individual call. The +per-call class is what was enforced and it travels in the audit chain, but a single +catalogued tool serving several classes cannot express that in the transcript today. See +[cmcp#479](https://github.com/agentrust-io/cmcp/issues/479). + +**Software-only** — `CMCP_DEV_MODE=1`, so `hardware_attestation` does not verify and the +claim reads `partially_verified`. On TDX or SEV-SNP that check passes too. diff --git a/demos/demo-10-model-gateway/catalog.json b/demos/demo-10-model-gateway/catalog.json new file mode 100644 index 00000000..25590667 --- /dev/null +++ b/demos/demo-10-model-gateway/catalog.json @@ -0,0 +1,74 @@ +[ + { + "tool_name": "model.chat_completion", + "server": { + "display_name": "Model backend", + "url": "http://localhost:9010/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "rotation_mode": "key-pinned", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Run a chat completion against a model in the catalog", + "input_schema": { + "type": "object", + "required": [ + "model", + "prompt" + ], + "properties": { + "model": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "model_region": { + "type": "string" + }, + "model_cloud": { + "type": "string" + }, + "data_class": { + "type": "string" + }, + "contains_identifiers": { + "type": "boolean" + }, + "redacted": { + "type": "boolean" + }, + "redaction_count": { + "type": "number" + } + } + }, + "output_schema": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "region": { + "type": "string" + }, + "cloud": { + "type": "string" + }, + "content": { + "type": "string" + }, + "upstream": { + "type": "string" + } + } + } + }, + "definition_hash": "sha256:a1df74a4a40c99c90cdd565e0093e107aec863efc47a135e1a9940b2d7ff9384", + "compliance_domain": "internal", + "requires_baa": false, + "sensitivity_level": "confidential", + "added_at": "2026-08-06T00:00:00Z", + "approved_by": "demo-setup" + } +] diff --git a/demos/demo-10-model-gateway/client.py b/demos/demo-10-model-gateway/client.py new file mode 100644 index 00000000..6e44c0a7 --- /dev/null +++ b/demos/demo-10-model-gateway/client.py @@ -0,0 +1,106 @@ +"""The caller's own code. An ordinary OpenAI client, pointed one line differently. + + base_url = "https://api.openai.com/v1" before + base_url = "http://127.0.0.1:8500/v1" after + +Nothing else changes: same SDK, same request shape, same response handling. +""" +import json +import sys +import urllib.error +import urllib.request + +from openai import OpenAI + +GATEWAY = "http://127.0.0.1:8500/v1" + +# Four sensitivity classes from cMCP's own vocabulary, against models in two +# regions and two tenancies. The interesting rows are the last three. +CALLS = [ + ("1", "frontier-large", "public", + "Summarise the published service-level commitments for standard support."), + ("2", "frontier-large", "pii", + "Customer 123-45-6789 (card 4444-3333-2222-1111) reports a duplicate charge " + "on the March invoice. Draft the acknowledgement."), + ("3", "frontier-large", "confidential", + "Summarise the unreleased pricing model for the enterprise tier."), + ("4", "regional-small", "hipaa_phi", + "Summarise the attached clinical note for the care coordination record."), + ("5", "regional-isolated", "hipaa_phi", + "Summarise the attached clinical note for the care coordination record."), +] + +BAR = "-" * 78 + + +def main(): + client = OpenAI( + base_url=GATEWAY, # <-- the only change + default_headers={"api-key": "demo-key"}, + api_key="demo-key", + ) + + print(f"\nbase_url = {GATEWAY}\n") + + for num, model, data_class, prompt in CALLS: + print(BAR) + print(f"[{num}] {model:18} data class: {data_class}") + print(f" prompt: {prompt[:62]}{'...' if len(prompt) > 62 else ''}") + try: + resp = client.chat.completions.create( + model=model, + messages=[{"role": "user", "content": prompt}], + extra_headers={"x-data-class": data_class}, + ) + except Exception as exc: + detail = {} + body = getattr(exc, "response", None) + if body is not None: + try: + detail = body.json().get("error", {}).get("governance", {}) + except Exception: + pass + advice = detail.get("advice") or {} + print(f" -> DENIED {advice.get('reason', '')}") + for k, v in advice.items(): + if k != "reason": + print(f" {k}: {v}") + continue + + extra = getattr(resp, "governance", None) or {} + red = extra.get("redactions") or [] if isinstance(extra, dict) else [] + note = f" redacted: {', '.join(red)}" if red else "" + print(f" -> ALLOWED{note}") + print(f" {resp.choices[0].message.content.splitlines()[0][:66]}") + + print(BAR) + print("\nClosing the session and fetching the signed claim...\n") + try: + with urllib.request.urlopen(f"{GATEWAY}/trust-record", timeout=20) as r: + record = json.loads(r.read()) + except urllib.error.HTTPError as exc: + detail = exc.read().decode(errors="replace")[:300] + print(f" could not fetch the signed claim: HTTP {exc.code} {detail}") + print(" Nothing to sign means no call was allowed. Check model-gateway.log for") + print(" UPSTREAM_CATALOG_DRIFT: catalog.json and model_server.py must describe") + print(" the same tool, or the gateway fail-closes on every call.") + return 1 + trace = record.get("trace", {}) + gw = record.get("gateway", {}) + print(f" policy.bundle_hash {trace.get('policy', {}).get('bundle_hash', '')}") + print(f" enforcement_mode {trace.get('policy', {}).get('enforcement_mode', '')}") + tt = trace.get("tool_transcript", {}) + print(f" calls {tt.get('call_count')}") + for e in tt.get("entries", []): + print(f" {e.get('tool_name')} -> {e.get('decision')}") + print(f" audit_chain.length {gw.get('audit_chain', {}).get('length')}") + print(f" signature {str(record.get('signature'))[:44]}...") + + with open("trace-claim.json", "w", encoding="utf-8") as fh: + json.dump(record, fh, indent=2) + print("\n saved to demo-10-model-gateway/trace-claim.json") + print(" verify it offline: cmcp verify trace-claim.json\n") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/demos/demo-10-model-gateway/cmcp-config.yaml b/demos/demo-10-model-gateway/cmcp-config.yaml new file mode 100644 index 00000000..c9cec8a4 --- /dev/null +++ b/demos/demo-10-model-gateway/cmcp-config.yaml @@ -0,0 +1,9 @@ +attestation: + provider: auto # software-only when CMCP_DEV_MODE=1 + enforcement_mode: enforcing + validity_seconds: 3600 + +policy_bundle_path: ./policies/ +catalog_path: ./catalog.json +listen_addr: "127.0.0.1:8444" +audit_db_path: ./audit.db diff --git a/demos/demo-10-model-gateway/endpoint.py b/demos/demo-10-model-gateway/endpoint.py new file mode 100644 index 00000000..56dbf11f --- /dev/null +++ b/demos/demo-10-model-gateway/endpoint.py @@ -0,0 +1,225 @@ +"""An OpenAI-compatible endpoint that governs the model call and signs what happened. + +Model and data protection at the model boundary: what data may reach which model, +in which region, on what infrastructure, with the outcome signed. An existing +OpenAI client is pointed at it by changing one line, and every call is routed +through cMCP, where Cedar decides, the audit chain records, and the session closes +into a signed TRACE claim. + +What is real here: policy enforcement, the audit chain, the signed record, and +offline verification. What stands in: the inference itself (see model_server.py) +and the redaction ruleset, which is a small demonstrative pattern set rather +than a production DLP engine. +""" +import json +import os +import re +import time +import urllib.error +import urllib.request +import uuid + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + +GATEWAY = os.environ.get("CMCP_GATEWAY", "http://127.0.0.1:8444") + +# The catalogue spans regions *and* infrastructure tenancy. Both dimensions matter: +# confidential stays in the jurisdiction, and PHI also stays off shared hardware. +MODELS = { + "regional-small": {"region": "in-region", "cloud": "shared"}, + "regional-isolated": {"region": "in-region", "cloud": "dedicated"}, + "frontier-large": {"region": "out-of-region", "cloud": "shared"}, + "frontier-mini": {"region": "out-of-region", "cloud": "shared"}, +} + +# Demonstrative identifier patterns. A production deployment would drive this +# from the customer's classification policy, not a literal list. +REDACTIONS = [ + ("NATIONAL_ID", re.compile(r"\b\d{3}-\d{2}-\d{4}\b")), + ("ACCOUNT_NO", re.compile(r"\b\d{4}-\d{4}-\d{4}-\d{4}\b")), + ("EMAIL", re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.]{2,}\b")), +] + +# The classes policies/allow.cedar knows, lowest to highest. +DATA_CLASSES = frozenset({"public", "pii", "confidential", "hipaa_phi"}) + +# Sessions we have opened on the gateway, and the records they produced. +STATE = {"session_id": None, "records": []} + + +def _post(url, payload, timeout=20): + req = urllib.request.Request( + url, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}, method="POST") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read()), resp.status + except urllib.error.HTTPError as exc: + raw = exc.read() + try: + return json.loads(raw), exc.code + except Exception: + return {"error": raw.decode(errors="replace")}, exc.code + + +def redact(text): + """Strip identifiers before anything leaves the boundary. Returns (text, hits).""" + hits = [] + for label, pattern in REDACTIONS: + text, n = pattern.subn(f"[{label} REDACTED]", text) + if n: + hits.extend([label] * n) + return text, hits + + +def _bad_request(message): + return JSONResponse({"error": {"message": message, "type": "invalid_request_error", + "code": "INVALID_REQUEST"}}, status_code=400) + + +def _prompt_of(messages): + return "\n".join(m.get("content", "") for m in messages if isinstance(m, dict)) + + +async def chat_completions(request: Request) -> JSONResponse: + """The OpenAI surface. Same request and response shape the SDK expects.""" + try: + body = await request.json() + except Exception: + return _bad_request("request body is not valid JSON") + if not isinstance(body, dict): + return _bad_request("request body must be a JSON object") + model = body.get("model", "regional-small") + messages = body.get("messages", []) + if not isinstance(model, str) or not isinstance(messages, list) or not all( + isinstance(m, dict) and isinstance(m.get("content", ""), str) for m in messages): + return _bad_request("model must be a string and messages a list of " + "{role, content} objects with string content") + + # The class is decided by the routing layer, not by the caller: an upstream + # classifier decides, this endpoint receives that decision and enforces it. + data_class = (request.headers.get("x-data-class") + or body.get("data_class") or "confidential") + # The Cedar bundle forbids by naming classes, so a label it does not name + # (a typo, a different case) would match no forbid and leave the region + # under the permit. Refuse it here rather than fail open. + if data_class not in DATA_CLASSES: + return _bad_request(f"unknown data class {data_class!r}; " + f"expected one of {sorted(DATA_CLASSES)}") + placement = MODELS.get(model, {"region": "unknown", "cloud": "unknown"}) + region, cloud = placement["region"], placement["cloud"] + + prompt = _prompt_of(messages) + contains_identifiers = any(p.search(prompt) for _, p in REDACTIONS) + safe_prompt, hits = redact(prompt) + + args = { + "model": model, + "prompt": safe_prompt, + "model_region": region, + "model_cloud": cloud, + "data_class": data_class, + "contains_identifiers": contains_identifiers, + "redacted": bool(hits), + "redaction_count": len(hits), + } + + result, status = _post(f"{GATEWAY}/mcp", { + "jsonrpc": "2.0", "id": int(time.time() * 1000), + "method": "tools/call", + "params": {"name": "model.chat_completion", "arguments": args, + "_cmcp": {"workflow_id": "model-gateway-demo"}}, + }) + + if "error" in result: + err = result["error"] + data = err.get("data", {}) if isinstance(err, dict) else {} + advice = data.get("advice", {}) or {} + # Deny is returned in the shape an OpenAI client already understands. + return JSONResponse({"error": { + "message": advice.get("reason") or err.get("message", "denied by policy") + if isinstance(err, dict) else str(err), + "type": "policy_denied", + "code": data.get("error_code", "POLICY_DENY"), + "governance": { + "decision": "deny", + "advice": advice, + "data_class": data_class, + "model_region": region, + "model_cloud": cloud, + "redactions": hits, + }, + }}, status_code=403) + + payload = result.get("result", {}) + inner = payload.get("content", [{}])[0].get("text", "{}") + try: + upstream = json.loads(inner) + except Exception: + upstream = {"content": inner} + STATE["session_id"] = payload.get("_cmcp", {}).get("session_id") or STATE["session_id"] + + return JSONResponse({ + "id": f"chatcmpl-{uuid.uuid4().hex[:24]}", + "object": "chat.completion", + "created": int(time.time()), + "model": model, + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": upstream.get("content", "")}, + "finish_reason": "stop", + }], + "usage": {"prompt_tokens": len(prompt.split()), + "completion_tokens": len(upstream.get("content", "").split()), + "total_tokens": len(prompt.split()) + len(upstream.get("content", "").split())}, + # The addition. Everything above is an ordinary OpenAI response. + "governance": { + "decision": "allow", + "data_class": data_class, + "model_region": region, + "model_cloud": cloud, + "redactions": hits, + "session_id": STATE["session_id"], + "trace_claim": "/v1/trust-record", + }, + }) + + +async def models(request: Request) -> JSONResponse: + return JSONResponse({"object": "list", "data": [ + {"id": m, "object": "model", "owned_by": "demo", **v} + for m, v in MODELS.items() + ]}) + + +async def trust_record(request: Request) -> JSONResponse: + """Close the session and return the signed record for it.""" + sid = STATE.get("session_id") + if not sid: + return JSONResponse({"error": "no session yet -- make a call first"}, 404) + record, status = _post(f"{GATEWAY}/sessions/{sid}/close", {}) + if status >= 400: + return JSONResponse({"error": record}, status) + STATE["records"].append(record) + STATE["session_id"] = None + return JSONResponse(record) + + +async def health(request: Request) -> JSONResponse: + return JSONResponse({"status": "ok", "gateway": GATEWAY}) + + +app = Starlette(routes=[ + Route("/v1/chat/completions", chat_completions, methods=["POST"]), + Route("/v1/models", models, methods=["GET"]), + Route("/v1/trust-record", trust_record, methods=["GET", "POST"]), + Route("/health", health, methods=["GET"]), +]) + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="127.0.0.1", port=8500, log_level="warning") diff --git a/demos/demo-10-model-gateway/model_server.py b/demos/demo-10-model-gateway/model_server.py new file mode 100644 index 00000000..14cfa6f4 --- /dev/null +++ b/demos/demo-10-model-gateway/model_server.py @@ -0,0 +1,155 @@ +"""Model backend for the model-gateway demo. + +Plain HTTP JSON-RPC 2.0, the shape the gateway's proxy posts to. Exposes one tool, +``model.chat_completion``, which stands in for the upstream inference call. + +The completion text is generated locally so the demo runs with no API key and no +network. Set MODEL_API_KEY and MODEL_BASE_URL to forward to a real provider +instead; the governed path either side of this file is unchanged. +""" +import json +import os +import pathlib + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + +HERE = pathlib.Path(__file__).parent + +# In-region models are the ones a regulated customer can already reach. The rest +# are the ones this demo exists to unblock. +MODELS = { + "regional-small": {"region": "in-region", "cloud": "shared"}, + "regional-isolated": {"region": "in-region", "cloud": "dedicated"}, + "frontier-large": {"region": "out-of-region", "cloud": "shared"}, + "frontier-mini": {"region": "out-of-region", "cloud": "shared"}, +} + +UPSTREAM_KEY = os.environ.get("MODEL_API_KEY") +UPSTREAM_BASE = os.environ.get("MODEL_BASE_URL", "https://api.openai.com/v1") + + +def _synthesise(model: str, prompt: str) -> str: + """Stand-in for inference. Deterministic, so the demo reads the same every time.""" + head = prompt.strip().splitlines()[0][:110] if prompt.strip() else "(empty prompt)" + return ( + f"[{model}] Draft response to: “{head}”\n\n" + "Eligibility for the requested benefit is confirmed against the submitted " + "documents. Two items need manual follow-up before payment is released: " + "proof of residence is older than 90 days, and the declared dependants do " + "not match the registry extract." + ) + + +def _upstream(model: str, messages: list) -> str | None: + """Forward to a real provider when a key is present.""" + if not UPSTREAM_KEY: + return None + try: + from openai import OpenAI + + client = OpenAI(base_url=UPSTREAM_BASE, + default_headers={"api-key": UPSTREAM_KEY}, + api_key=UPSTREAM_KEY) + resp = client.chat.completions.create(model=model, messages=messages) + return resp.choices[0].message.content + except Exception as exc: # a demo must not die because upstream is unhappy + return f"[upstream call failed: {exc}]" + + +def _ok(id_, result) -> dict: + return {"jsonrpc": "2.0", "id": id_, + "result": {"content": [{"type": "text", "text": json.dumps(result)}]}} + + +def _err(id_, code: int, msg: str) -> dict: + return {"jsonrpc": "2.0", "id": id_, "error": {"code": code, "message": msg}} + + +# The catalog's approved_definition is the contract. cMCP hashes description + +# input schema + output schema on both sides and fail-closes on any difference, +# so this dict and catalog.json's approved_definition must stay identical: an +# argument the endpoint sends but this schema omits reads as a rug pull. +TOOL = { + "name": "model.chat_completion", + "description": "Run a chat completion against a model in the catalog", + "inputSchema": { + "type": "object", + "required": ["model", "prompt"], + "properties": { + "model": {"type": "string"}, + "prompt": {"type": "string"}, + "model_region": {"type": "string"}, + "model_cloud": {"type": "string"}, + "data_class": {"type": "string"}, + "contains_identifiers": {"type": "boolean"}, + "redacted": {"type": "boolean"}, + "redaction_count": {"type": "number"}, + }, + }, + "outputSchema": { + "type": "object", + "properties": { + "model": {"type": "string"}, + "region": {"type": "string"}, + "cloud": {"type": "string"}, + "content": {"type": "string"}, + "upstream": {"type": "string"}, + }, + }, +} + + +async def handle(request: Request) -> JSONResponse: + if request.method == "GET": + return JSONResponse({"status": "ok"}) + + try: + body = await request.json() + except Exception: + return JSONResponse(_err(None, -32700, "parse error"), status_code=400) + + id_ = body.get("id") + method = body.get("method", "") + params = body.get("params", {}) + + if method == "initialize": + return JSONResponse({"jsonrpc": "2.0", "id": id_, "result": { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "demo-model-backend", "version": "0.1.0"}, + }}) + + if method == "tools/list": + return JSONResponse({"jsonrpc": "2.0", "id": id_, "result": {"tools": [TOOL]}}) + + if method == "tools/call": + name = params.get("name") + args = params.get("arguments", {}) or {} + if name != "model.chat_completion": + return JSONResponse(_err(id_, -32601, f"unknown tool: {name}")) + + model = args.get("model", "gpt-oss-20b") + prompt = args.get("prompt", "") + messages = [{"role": "user", "content": prompt}] + + text = _upstream(model, messages) or _synthesise(model, prompt) + return JSONResponse(_ok(id_, { + "model": model, + "region": MODELS.get(model, {}).get("region", "unknown"), + "cloud": MODELS.get(model, {}).get("cloud", "unknown"), + "content": text, + "upstream": "provider" if UPSTREAM_KEY else "local-stand-in", + })) + + return JSONResponse(_err(id_, -32601, f"method not found: {method}")) + + +app = Starlette(routes=[Route("/mcp", handle, methods=["GET", "POST"])]) + +if __name__ == "__main__": + import uvicorn + + uvicorn.run(app, host="127.0.0.1", port=9010, log_level="warning") diff --git a/demos/demo-10-model-gateway/policies/allow.cedar b/demos/demo-10-model-gateway/policies/allow.cedar new file mode 100644 index 00000000..30e913c3 --- /dev/null +++ b/demos/demo-10-model-gateway/policies/allow.cedar @@ -0,0 +1,77 @@ +// Demo 10 -- routing a model call by the sensitivity of the data it carries. +// +// AGT's CedarBackend maps a dotted tool name to a Cedar action: +// model.chat_completion becomes Action::"Model.chatCompletion" +// +// The classes below are cMCP's own vocabulary, in ascending order: +// +// public may go anywhere +// pii may leave the region once identifiers are stripped at the boundary +// confidential must stay in the jurisdiction +// hipaa_phi must stay in the jurisdiction *and* off shared infrastructure +// +// The point of the ladder is the top of it. Proof makes an out-of-region call +// provable, not permissible: a residency rule still binds at confidential, and at +// hipaa_phi the jurisdiction is necessary but not sufficient. + +// --- The permit --------------------------------------------------------------- + +@id("model-calls-permitted") +permit ( + principal, + action == Action::"Model.chatCompletion", + resource +); + +// --- Guardrails --------------------------------------------------------------- +// Each forbid overrides the permit and carries advice back to the caller in +// error.data.advice, which the endpoint surfaces to the developer. + +// Confidential and above stay in the jurisdiction. +@id("confidential-stays-in-region") +@reason("data-residency") +forbid ( + principal, + action == Action::"Model.chatCompletion", + resource +) when { + context.arguments has model_region && + context.arguments.model_region != "in-region" && + context.arguments has data_class && + (context.arguments.data_class == "confidential" || + context.arguments.data_class == "hipaa_phi") +}; + +// Protected health information additionally may not run on shared infrastructure, +// even inside the region. Jurisdiction is necessary but not sufficient. +@id("phi-requires-dedicated-infrastructure") +@reason("phi-isolation") +forbid ( + principal, + action == Action::"Model.chatCompletion", + resource +) when { + context.arguments has data_class && + context.arguments.data_class == "hipaa_phi" && + context.arguments has model_cloud && + context.arguments.model_cloud != "dedicated" +}; + +// Identifiers are stripped at the boundary before anything classified leaves the +// region. The endpoint performs the redaction and reports it; this rule is what +// makes the report load-bearing rather than advisory. +@id("identifiers-redacted-before-egress") +@reason("identifiers-must-be-redacted") +forbid ( + principal, + action == Action::"Model.chatCompletion", + resource +) when { + context.arguments has model_region && + context.arguments.model_region != "in-region" && + context.arguments has data_class && + context.arguments.data_class == "pii" && + context.arguments has contains_identifiers && + context.arguments.contains_identifiers == true && + !(context.arguments has redacted && context.arguments.redacted == true) +}; diff --git a/demos/demo-10-model-gateway/policies/manifest.json b/demos/demo-10-model-gateway/policies/manifest.json new file mode 100644 index 00000000..81e797df --- /dev/null +++ b/demos/demo-10-model-gateway/policies/manifest.json @@ -0,0 +1,7 @@ +{ + "version": "1.0.0", + "authored_at": "2026-08-06T00:00:00Z", + "author_identity": "demo-setup", + "commit_sha": "demo-do-not-use-in-production", + "approval_chain": [] +} diff --git a/demos/demo-10-model-gateway/policies/schema.cedarschema b/demos/demo-10-model-gateway/policies/schema.cedarschema new file mode 100644 index 00000000..a3b37be6 --- /dev/null +++ b/demos/demo-10-model-gateway/policies/schema.cedarschema @@ -0,0 +1,67 @@ +{ + "cMCP": { + "entityTypes": { + "Principal": { + "memberOfTypes": [], + "shape": { + "type": "Record", + "attributes": { + "session_id": { + "type": "String", + "required": true + }, + "workflow_id": { + "type": "String", + "required": true + } + } + } + }, + "Resource": { + "memberOfTypes": [], + "shape": { + "type": "Record", + "attributes": { + "tool_name": { + "type": "String", + "required": true + }, + "compliance_domain": { + "type": "String", + "required": true + }, + "baa_covered": { + "type": "Bool", + "required": true + } + } + } + } + }, + "actions": { + "call_tool": { + "appliesTo": { + "principalTypes": [ + "cMCP::Principal" + ], + "resourceTypes": [ + "cMCP::Resource" + ], + "context": { + "type": "Record", + "attributes": { + "session_max_sensitivity": { + "type": "String", + "required": true + }, + "workflow_id": { + "type": "String", + "required": true + } + } + } + } + } + } + } +} diff --git a/demos/demo-10-model-gateway/run.py b/demos/demo-10-model-gateway/run.py new file mode 100644 index 00000000..c3dfd009 --- /dev/null +++ b/demos/demo-10-model-gateway/run.py @@ -0,0 +1,118 @@ +"""Demo 10: a governed, OpenAI-compatible model endpoint. + + python demo-10-model-gateway/run.py # start everything, run the client + python demo-10-model-gateway/run.py --serve # stay up, drive it yourself + +Ctrl+C stops all three. Logs go to *.log beside this file. +""" +import argparse +import os +import pathlib +import shutil +import socket +import subprocess +import sys +import time + +HERE = pathlib.Path(__file__).parent +PORTS = {"model backend": 9010, "model gateway": 8444, "endpoint": 8500} + + +def _wait(port, name, process=None, timeout=60): + deadline = time.time() + timeout + while time.time() < deadline: + if process is not None and process.poll() is not None: + print(f"{name} exited with code {process.returncode} before listening on :{port}. " + "See the *.log files.", file=sys.stderr) + return False + with socket.socket() as s: + s.settimeout(0.5) + if s.connect_ex(("127.0.0.1", port)) == 0: + return True + time.sleep(0.3) + print(f"{name} did not come up on :{port}. See the *.log files.", file=sys.stderr) + return False + + +def _free(port): + with socket.socket() as s: + s.settimeout(0.4) + return s.connect_ex(("127.0.0.1", port)) != 0 + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--serve", action="store_true", help="stay up instead of running the client") + args = ap.parse_args() + + config = "cmcp-config.yaml" + + busy = [f"{n} (:{p})" for n, p in PORTS.items() if not _free(p)] + if busy: + print("These ports are already in use: " + ", ".join(busy)) + print("Stop the older run first, or the gateway will refuse to bind.") + return 1 + + cmcp = shutil.which("cmcp") + if not cmcp: + print("`cmcp` is not on PATH. Activate the venv that has cmcp-runtime installed:") + print(r' pip install -r requirements.txt') + return 1 + + env = dict(os.environ) + env["CMCP_DEV_MODE"] = "1" # software-only TEE, no hardware needed + env.pop("CMCP_BEARER_TOKEN", None) # loopback demo, tokenless like the web console + + logs = {n: open(HERE / f"{n.replace(chr(32), chr(45))}.log", "w") for n in PORTS} + procs = [] + try: + print("-- model backend on :9010") + procs.append(subprocess.Popen( + [sys.executable, str(HERE / "model_server.py")], + stdout=logs["model backend"], stderr=logs["model backend"], env=env, cwd=HERE)) + if not _wait(9010, "model backend", procs[-1]): + return 1 + + print("-- cMCP gateway on :8444 (CMCP_DEV_MODE=1)") + procs.append(subprocess.Popen( + [cmcp, "start", "--config", config], + stdout=logs["model gateway"], stderr=logs["model gateway"], env=env, cwd=HERE)) + if not _wait(8444, "model gateway", procs[-1]): + return 1 + + print("-- OpenAI-compatible endpoint on :8500") + procs.append(subprocess.Popen( + [sys.executable, str(HERE / "endpoint.py")], + stdout=logs["endpoint"], stderr=logs["endpoint"], env=env, cwd=HERE)) + if not _wait(8500, "endpoint", procs[-1]): + return 1 + + print() + if args.serve: + print("Up. base_url = http://127.0.0.1:8500/v1 Ctrl+C to stop.") + while True: + time.sleep(1) + else: + # The client's exit code is this demo's exit code. Discarding it kept + # CI green through a gateway that denied every call. + rc = subprocess.call([sys.executable, str(HERE / "client.py")], cwd=HERE) + if rc != 0: + print(f"client.py exited with code {rc}. See the *.log files.", file=sys.stderr) + return rc + except KeyboardInterrupt: + pass + finally: + for p in procs: + p.terminate() + for p in procs: + try: + p.wait(timeout=5) + except Exception: + p.kill() + for fh in logs.values(): + fh.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/demos/demo.py b/demos/demo.py new file mode 100644 index 00000000..b8ba5ce8 --- /dev/null +++ b/demos/demo.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""One command, the agentrust-io demos. + + python demo.py # run all, pausing before each (for live talks) + python demo.py --no-pause # run straight through, no prompts + python demo.py 2 # run only demo 2 (1 through 10) + +The trust chain, end to end: + Demo 1 cMCP enforces Cedar on every tool call and signs a TRACE claim. + Demo 2 Swap the policy bundle and the claim's hash changes; a pinned verifier rejects it. + Demo 3 Verify that signed claim offline: no server, no gateway, no network. + +Then, on a second axis, two ways the policy decides a call: + Demo 4 by call context -- the same tool, allowed in one workflow, denied in another. + Demo 5 by tool attribute -- a non-BAA-covered tool refused by one guardrail rule. + +And the layer beneath it all, the weights themselves: + Demo 6 Weight custody -- a signed manifest binds the exact weight hash; a tampered + checkpoint is refused before load, and a fine-tune's lineage verifies to the base. + Demo 7 Closed-weight custody -- the frontier case where secrecy is the job. + Demo 8 Derivative lineage -- the fine-tune is the real IP; its chain resolves to the base. + Demo 9 Sovereign threshold -- split the key 2-of-3 so no single release is enough. + +And one axis out from the tool boundary: + Demo 10 Governed model calls -- the same policy boundary on an OpenAI-compatible + endpoint, routing each request by its data class. + +All demos run in software-only mode (CMCP_DEV_MODE=1). That is deliberate: software +proves the whole chain except the hardware root, so verification reads +'partially_verified'. On real TDX / SEV-SNP the hardware field verifies too and it +becomes 'verified'. That last gap is exactly what the hardware path closes. +""" +import argparse +import os +import pathlib +import shutil +import subprocess +import sys + +ROOT = pathlib.Path(__file__).parent.resolve() + +DEMOS = [ + ("1", "cMCP in action", + "demo-01-cmcp-in-action/run.py", + "Three tool calls through cMCP. write_file and read_file are allowed; list_dir is\n" + " denied by Cedar. The session closes into a signed TRACE claim."), + ("2", "Policy swap = attestation failure", + "demo-02-policy-swap/run.py", + "Load a different Cedar bundle. The policy hash changes. Watch the\n" + " policy_bundle.hash line flip FAIL -> PASS when the pinned hash matches."), + ("3", "Offline TRACE verification", + "demo-03-offline-trace/run.py", + "Verify the demo-1 claim with nothing but the claim and a public key. No network."), + ("4", "Context-aware enforcement", + "demo-04-context-enforcement/run.py", + "The same tool is allowed in one workflow and denied in another. Enforcement is\n" + " on the declared call context, not on the model's stated intent."), + ("5", "Attribute-based enforcement", + "demo-05-compliance-domain/run.py", + "A tool that is not BAA-covered is refused by one guardrail rule, whatever it is\n" + " named. The decision is on the tool's compliance attribute, not its identity."), + ("6", "Weight custody", + "demo-06-weight-custody/run.py", + "A checkpoint signed to its exact weight hash. Attestation gates the key, a\n" + " tampered fork is refused before load, and a fine-tune's lineage verifies back to\n" + " the signed base. Possession is not provenance."), + ("7", "Closed-weight custody", + "demo-07-closed-weight/run.py", + "The frontier case where secrecy is the job: closed weights, the key releases only\n" + " into the attested lab-signed enclave, and an unapproved serving stack gets\n" + " nothing. The mirror of demo 6's open-weight framing."), + ("8", "Derivative lineage", + "demo-08-derivative-lineage/run.py", + "The fine-tune is the real IP. A derivative gets its own manifest with derived_from\n" + " and a rights split; lineage resolves it to the base, and the base's terms travel\n" + " down the chain (a fork of a no-derivatives model is rejected)."), + ("9", "Sovereign threshold", + "demo-09-sovereign-threshold/run.py", + "The model key is split 2-of-3 across independent parties, each releasing its share\n" + " only against attestation. A quorum reconstructs; one forged root is below\n" + " threshold, which is why threshold is a prerequisite for sovereign self-custody."), + ("10", "Governed model calls", + "demo-10-model-gateway/run.py", + "The other demos govern tool calls; this one governs model calls. An\n" + " OpenAI-compatible endpoint routes by data class: PII leaves only once\n" + " identifiers are stripped, confidential stays in region, and PHI also\n" + " stays off shared infrastructure."), +] + +GREEN = "\033[92m"; BLUE = "\033[96m"; DIM = "\033[90m"; BOLD = "\033[1m"; RST = "\033[0m" + + +def _c(s, color): + # colour only when attached to a real terminal + return f"{color}{s}{RST}" if sys.stdout.isatty() else s + + +def banner(idx, title, blurb): + line = "=" * 70 + print() + print(_c(line, DIM)) + print(_c(f" DEMO {idx}: {title}", BOLD + BLUE)) + print(f" {blurb}") + print(_c(line, DIM)) + print() + + +def _venv_python(): + p = ROOT / ".venv" / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + return p if p.exists() else None + + +def _reexec_into_venv_if_needed(): + """If this interpreter can't resolve cmcp but the demo .venv can, re-run there. + Makes `python demo.py` work regardless of which Python is on PATH.""" + if os.environ.get("_DEMO_REEXEC"): + return + if shutil.which("cmcp") or _cmcp_in_scripts(): + return + vp = _venv_python() + if not vp: + return + os.environ["_DEMO_REEXEC"] = "1" + rc = subprocess.run([str(vp), str(pathlib.Path(__file__).resolve()), *sys.argv[1:]]).returncode + sys.exit(rc) + + +def preflight(): + if not (shutil.which("cmcp") or _cmcp_in_scripts()): + print(_c("cmcp not found.", BOLD)) + print("Set up the demo environment once:") + print(" python -m venv .venv") + print(" .venv\\Scripts\\python -m pip install cmcp-runtime # (Scripts/ -> bin/ on macOS/Linux)") + print("Then just run: python demo.py") + sys.exit(1) + ver = _cmcp_version() + print(_c(f"cmcp-runtime {ver or '(unknown)'} detected.", DIM)) + if ver and _older_than(ver, (0, 3, 0)): + print(_c( + " WARNING: the demo narration assumes 0.3.0+. On this older version, demo 2\n" + " step 6 and demo 3 will read 'verified' instead of 'partially_verified'.\n" + " For the talk, upgrade: pip install -U cmcp-runtime", BOLD)) + + +def _cmcp_version(): + try: + import importlib.metadata as m + return m.version("cmcp-runtime") + except Exception: + return None + + +def _older_than(ver, target): + try: + parts = tuple(int(x) for x in ver.split(".")[:3]) + return parts < target + except Exception: + return False + + +def _cmcp_in_scripts(): + import sysconfig + for base in (pathlib.Path(sys.executable).parent, + pathlib.Path(sysconfig.get_path("scripts")), + pathlib.Path(sysconfig.get_path("scripts", "nt_user"))): + for name in ("cmcp.exe", "cmcp"): + if (base / name).exists(): + return True + return False + + +def run(idx, title, script, blurb, pause): + if pause: + try: + input(_c(f">>> Press Enter to run Demo {idx}: {title} ", GREEN)) + except (EOFError, KeyboardInterrupt): + print("\nStopped."); sys.exit(0) + banner(idx, title, blurb) + try: + rc = subprocess.run( + [sys.executable, str(ROOT / script)], timeout=180 + ).returncode + except subprocess.TimeoutExpired: + print(_c(f"\n[!] Demo {idx} timed out after 180 seconds.", BOLD)) + return 1 + if rc != 0: + print(_c(f"\n[!] Demo {idx} exited with code {rc}. See the *.log files in the demo folder.", BOLD)) + return rc + + +def main(): + ap = argparse.ArgumentParser(description="Run the agentrust-io trust-chain demos.") + ap.add_argument("only", nargs="?", choices=[d[0] for d in DEMOS], help="run only this demo") + ap.add_argument("--no-pause", action="store_true", help="run straight through, no prompts") + args = ap.parse_args() + + os.environ.setdefault("CMCP_BEARER_TOKEN", "demo-token") + os.environ.setdefault("CMCP_DEV_MODE", "1") + _reexec_into_venv_if_needed() + preflight() + + print(_c("\nagentrust-io · the trust chain, live", BOLD)) + print(_c("Agent Manifest (what the agent is) · cMCP (what it does) · TRACE (the proof)", DIM)) + + selected = [d for d in DEMOS if (args.only is None or d[0] == args.only)] + pause = not args.no_pause and sys.stdin.isatty() + + failures = 0 + for idx, title, script, blurb in selected: + if run(idx, title, script, blurb, pause) != 0: + failures += 1 + + print() + if failures: + print(_c(f"Done with {failures} failure(s).", BOLD)) + sys.exit(1) + print(_c("Done. The proof outlives the runtime that made it.", BOLD + GREEN)) + + +if __name__ == "__main__": + main() diff --git a/demos/requirements.txt b/demos/requirements.txt new file mode 100644 index 00000000..7c4b38ad --- /dev/null +++ b/demos/requirements.txt @@ -0,0 +1,8 @@ +# 0.5.0 is the floor: server.rotation_mode became expressible in the catalog +# schema and compliance_domain stopped being a closed enum, both of which these +# demos now use. cmcp-runtime needs a floor. Without one, an unsatisfiable constraint elsewhere in +# this file makes pip backtrack to 0.1.0 rather than report a conflict, and the demo +# then dies at startup instead of at install time. See PR body. +cmcp-runtime>=0.5.0 +weight-custody-manifest>=0.28.2,<0.29.0 +openai>=2.54.0 diff --git a/demos/requirements/ci.in b/demos/requirements/ci.in new file mode 100644 index 00000000..bdb77c6d --- /dev/null +++ b/demos/requirements/ci.in @@ -0,0 +1,9 @@ +# CI lock for the demos workflow. requirements.txt stays the human-facing +# install line and the single place the floors live; this file adds pytest and +# httpx (starlette's TestClient, used by tests/test_request_hardening.py) on +# top of it. Installed with --require-hashes. Compile with: +# uv pip compile requirements/ci.in --generate-hashes --universal \ +# --python-version 3.11 -o requirements/ci.txt +-r ../requirements.txt +pytest>=8.0 +httpx>=0.27 diff --git a/demos/requirements/ci.txt b/demos/requirements/ci.txt new file mode 100644 index 00000000..4e066063 --- /dev/null +++ b/demos/requirements/ci.txt @@ -0,0 +1,859 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements/ci.in --generate-hashes --universal --python-version 3.11 -o requirements/ci.txt +agent-manifest==0.12.0 \ + --hash=sha256:ad8380f39d906054ff07852386bb5caeb3525c82126ccc6b5d463656ba41a8e1 \ + --hash=sha256:beb4ead3f8f7782563f2a715371a45322c5a927962743eeb1b33ccb07b768177 + # via cmcp-runtime +agentrust-trace==0.10.0 \ + --hash=sha256:a82900c426c92f71ec244a13123611953771d232eb02137fec72927225ab472b \ + --hash=sha256:e3ef4438ecb8eb9cbbf1aed13d2d409673ef46afe424c8dc579b8ecce79f2b46 + # via cmcp-runtime +annotated-types==0.8.0 \ + --hash=sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7 \ + --hash=sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0 + # via pydantic +anyio==4.15.1 \ + --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \ + --hash=sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94 + # via + # cmcp-runtime + # httpx + # httpx2 + # openai + # starlette +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + # via + # jsonschema + # referencing +cbor2==6.1.4 \ + --hash=sha256:01ecc79a28f33d17331943ce508fc1e21f4b06553c73f874f4c77120d72b2ef9 \ + --hash=sha256:032ce71cbdc9267e9cec42132f6ac6c40f441ec27ebc87ca9dd209dcab6804ee \ + --hash=sha256:0859a0837e6e2d4fe5f5b849f6475797e4db545da98c19db4b1d3487bd47aa22 \ + --hash=sha256:164ca22b509408435b2d8236c80c964e4fc77c085ab034569cd04c40d5cc8883 \ + --hash=sha256:179a794bf4be1d46ff190695929f65f0b42019c156919846ae539d2a7ec42e54 \ + --hash=sha256:1fc15061553e4494dc10883237501e3402c645fe509248dd698e1faf2460d68b \ + --hash=sha256:2310f07db3f9ba26f2a623774ff9f3dc7185af54f732ea119785a6b1bf7e1e7e \ + --hash=sha256:239db0f92d537fd29eaec4e40195fc3b2b48bc34a5887059658162489a9eb6ae \ + --hash=sha256:24da0a481294ac416e1e369e2d204b2b1d993cbd082d0d99fa3d6f5f27ae5e69 \ + --hash=sha256:28fa5db05a7eae8fd80709959988d8a7f12838c6d4e5c58ec951414058641195 \ + --hash=sha256:310f3dfb296ba48fe9b63c5cf26e691e3548a1eae6901d2f0c18e941d151f220 \ + --hash=sha256:316e217a496640418d3137483279d0e70053b000cdd4b52a4dbf20ea478bc40a \ + --hash=sha256:32a4663425fbca4a4a7aa918eb5789d844c406439e58424cf34511f79f559242 \ + --hash=sha256:36ae16d64b1f7b620c1af748e7b6947e20069ef80eee56871c5fbb84cc635905 \ + --hash=sha256:3cfb7eec57406b86aab47183f6dc90ece888e03721d37a4d33315e79e486898d \ + --hash=sha256:3f4a434c36bb0d33aeb48ddae8e8b673ca7e1f14545ee7cf4a4c7c39380ea9a2 \ + --hash=sha256:41aae72e8863ac0d50705ea8290b72c49db90a6d953110feaa91360ae526fa8d \ + --hash=sha256:4903f24e0f9087275a0b6606c8b0aa586277001d51e4844fcdbc5b7211330aa8 \ + --hash=sha256:4bd29f21529e279d50fc14f1a811f7b05b4d8e66a7969163cce98983b6817245 \ + --hash=sha256:553a46bda7d09552631a714e22b91e6ff2c867ecd91511596ce290d8879b8d5b \ + --hash=sha256:598710183daae69cbdeb177a870ec64aa601de8138a61491fd256826d15a860f \ + --hash=sha256:5b99305d4013867e059f147752b95f728680682ab03d75a3f4dcfbb270d8dfe9 \ + --hash=sha256:5e6c76004d674ad1c620660cb0bc5a8a0b72a5d8c7b70926d8e09e6d7e87332f \ + --hash=sha256:69978901302ecbc8cda57b520487c5c5240ed217de783eb7728fceb258311d76 \ + --hash=sha256:6abcf072b8c0fdc8ad7902ee26a906cafbf3427d026b662ff21166a253f85e18 \ + --hash=sha256:71fcf4f237d68bf4445bf45070f36f82b333f2e6a62612aa2c256683b51378a9 \ + --hash=sha256:7336ff4cb7d161ec43b65eef43bf3e9bcab44bd152efb54dd637b7afe711254f \ + --hash=sha256:7b697c2a18eab9a3326447fe0f1c927db065a096febc57f064f0f5ed67fe96fc \ + --hash=sha256:7deccc50fd0b55c4c7dd265b144c5358a645121e457c0ae3722b5ad59832b257 \ + --hash=sha256:8156fdeb73c3ff6c8cf67ad414fb5c887cd708ff0af6d61f62629f41cb4c17b2 \ + --hash=sha256:855764e02dc60ab9413acd044e997c3170000fdea6155d6c43a923a1d966dbe6 \ + --hash=sha256:8f1019494b0ec81a3df3ebb01b6acb446d5b946fe35845b1726379abd66a71da \ + --hash=sha256:9b904b8d0f4ddac9259197d21d121fae4cb8b555700d65bc12c5d46a2e6c2025 \ + --hash=sha256:ad4efa23fee6447e56a269191044e06eb39e809458bcd674e164fe9445feafd0 \ + --hash=sha256:ae14eb3f2f251102d72625ab02a28c83a8d11adf3d0383bd6d7bd9d672e18119 \ + --hash=sha256:bd20ecc5c8ece24db952e48a91c8c47319eaa6358af707c85ac2bb388a79abc8 \ + --hash=sha256:c08b9c7d2ea013e24a0cb819b872b0119dde404f64a1182c0b24095b7bba781f \ + --hash=sha256:c0f5f2d6d3b58e44146860c049f3c082207a4005588b8926d51bf937ab66773c \ + --hash=sha256:c204a75f91f8cd9ed0881f6b88ec395c59aeac9fcf4d08155e7f899db2a1c46e \ + --hash=sha256:c435720a25c1de9b241df883b5890b35aff3bf64365efc8037d4d26df085724e \ + --hash=sha256:c48a7c938fc5fa5300ff82b5df09068dcb4838685ae8556b5ee8279d74f97ab4 \ + --hash=sha256:c6b28b928c5f2dbf47dffa12dce9c8e36fe6ac1c1358bc326499c0736263b66f \ + --hash=sha256:cc8cd300e236e9797b2e1ce306109dc481fcccf78bfa2682bf36d99e6eab1ec6 \ + --hash=sha256:d2560c2ba6a95904ba2a0ca257af878c4344409d9b46d8e646d8ebb617b1e0dd \ + --hash=sha256:d9ada5a6ccfbb8ea7a3aa2aeb028421b52d8e0cd9323f0a2aeaa9c09d25fbce2 \ + --hash=sha256:deff027a56e04caebfee649029bfd1836f4fb468d2cf66d2a396ae1018f655db \ + --hash=sha256:e1fe2d62c50df290576280b18247ec63486f78be73e285bae269c2456c6ddff0 \ + --hash=sha256:e8097e8259b78c8feb5dd5b833b23f83ed588e821ef44cf1455082cfc9434440 \ + --hash=sha256:f3fc7d15cba4174373df2496070faa4a927fe3ed772130d281808120aec7b61c + # via agent-manifest +cedarpy==4.12.1 \ + --hash=sha256:14630de081249519d76a6c9aca9f677251aa5aa722a056ac4e1f365db392f1ed \ + --hash=sha256:1c765643cc3c1472d85324f42147f3732b1c8dd47f71ff066386e7c8afe109b7 \ + --hash=sha256:2609aaa1e2041599592a821de3741c56b2a778b9c20e87ac97c84a0531120dbd \ + --hash=sha256:28f99b8c3f8faf0cd78a82c2a845360f60f47d54ab8b97ba64e19b04a42e70d5 \ + --hash=sha256:3010ab38bd691387944d57d925aab6b743694a89a1cd3de4295421161bad1609 \ + --hash=sha256:333e89642a5914037bead6ddc16ad4d246d7a27f227856c5636db5524d16e27c \ + --hash=sha256:34316ad5eb18d5ed34beb5b00398b1928f9add67dca6fef7f12f6bf11eb86e8d \ + --hash=sha256:357002b436d06ad00cd24c1dc27287933af9c45af0541b0cd9d52036cccfe8c2 \ + --hash=sha256:35e7446894bf8889e5c04118391b8720afee7dcd2e9d4ebecfa646674301fb96 \ + --hash=sha256:3c216ce8e4e9f8edb03154424dd99611e99533c3fb2f552147f61b0fcde5ac22 \ + --hash=sha256:42e238d25695f92898d4249cd659b67e6d9cde0c3f5ab1403cc642af5e4f5b85 \ + --hash=sha256:46429641712af012e1b2579cc1083eb5ab1ea7df2e7117d94b965cfe515255ba \ + --hash=sha256:547fda0b8f9948634a8b4445a449d4736de4312c11a2133aa5df7deb1d2b99fb \ + --hash=sha256:6f814415d96eed4d724b2ea34f1017217a7022a958307986eb009d5878d482c9 \ + --hash=sha256:7047ff6368a9e52453dbd2ac33f8d7d062a67ff1ee3f3a887f41c0a857f3f6e4 \ + --hash=sha256:74b3d6deb0d482f9645b7709016ab81dc7405be83270b10790b646a60f475518 \ + --hash=sha256:7f71d2285a4cd048866bd66aac36c0cff791554d6755f4230a71987f0b601b71 \ + --hash=sha256:80dca63da4cea16969d023daff31a15665ba54a769f633fb26ac5594b7ffd291 \ + --hash=sha256:832bdcd9586bd9597e52b6adf248adc77c1e2b0c55f3cfc10259feb6627bcd96 \ + --hash=sha256:84aaac429b3cae8b9d496aef9ef99426c130bde390767f46738dea04b7a05714 \ + --hash=sha256:869e4318dbd827aea996fe0b858592866fb7ad1888484b71b5fdd15334eaf8a0 \ + --hash=sha256:91410cbd0027a91a1a3dadf919f1d54bc75a62705247b833a004c5e24add1803 \ + --hash=sha256:9497da64835f431a0acdc6f393a841235bc0657188978238badae2ded3653b02 \ + --hash=sha256:970ecd03259e691d8b947c972b03d3ae34c7dd1d3e9e39d03fbe62dccc03227f \ + --hash=sha256:a935e3ed9e5873f88348469eb917e816335f62387d0f9ebed7058fac76c9a547 \ + --hash=sha256:bbcfb99ef9dc0c155677473d7e5bea8baac6ff2abe27b89a530a7cabb184e3e0 \ + --hash=sha256:c5eea3a4ce98862f1dc06bef1038ae8edf52c74d36980e8e75b59a38168f53ab \ + --hash=sha256:d00d151a1b2bd3697d778fd89597cc16aa1fca11da25594d7a000c725477f151 \ + --hash=sha256:e8ed70991b958f344017663f753f044070cd0e61dfd4ffe408ba05d48c48dea4 \ + --hash=sha256:f164ca11999e1d3a1641548d17755c3c45a5385b2590a642ef3122a1196947ca + # via cmcp-runtime +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via + # httpcore + # httpx +cffi==2.1.1 ; platform_python_implementation != 'PyPy' \ + --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 + # via cryptography +click==8.5.0 \ + --hash=sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360 \ + --hash=sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34 + # via + # cmcp-runtime + # uvicorn +cmcp-runtime==0.5.0 \ + --hash=sha256:be30f5aabe6af0d8c613811730cc22fa43487050b754dfdf36ff88527be147c0 \ + --hash=sha256:d327259ca879b4f8f2234a391197237cbabf5af8b50b5f87ec0edfc6e99fb6ef + # via -r requirements/../requirements.txt +colorama==0.4.6 ; sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via pytest +cryptography==50.0.1 \ + --hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \ + --hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \ + --hash=sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 \ + --hash=sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e \ + --hash=sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361 \ + --hash=sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054 \ + --hash=sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f \ + --hash=sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6 \ + --hash=sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49 \ + --hash=sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5 \ + --hash=sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149 \ + --hash=sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88 \ + --hash=sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad \ + --hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a \ + --hash=sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f \ + --hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 \ + --hash=sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20 \ + --hash=sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45 \ + --hash=sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f \ + --hash=sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b \ + --hash=sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527 \ + --hash=sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3 \ + --hash=sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6 \ + --hash=sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367 \ + --hash=sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0 \ + --hash=sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94 \ + --hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 \ + --hash=sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b \ + --hash=sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a \ + --hash=sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9 \ + --hash=sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5 \ + --hash=sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc \ + --hash=sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648 \ + --hash=sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986 \ + --hash=sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959 \ + --hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 \ + --hash=sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17 \ + --hash=sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e \ + --hash=sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733 \ + --hash=sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f \ + --hash=sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8 \ + --hash=sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf \ + --hash=sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671 \ + --hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \ + --hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \ + --hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef + # via + # agent-manifest + # agentrust-trace + # cmcp-runtime + # weight-custody-manifest +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via + # httpcore + # httpcore2 + # uvicorn +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpcore2==2.13.1 ; sys_platform != 'emscripten' \ + --hash=sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103 \ + --hash=sha256:e1e05d4f25f7d7d496bfb96748f6f4b67657b03da069b3a68c36069f3db73d0a + # via httpx2 +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via + # -r requirements/ci.in + # cmcp-runtime +httpx2==2.13.1 \ + --hash=sha256:6dff50fabc270ee5fd25d845d0b078ed20564579744d6d962850975996d2f9a4 \ + --hash=sha256:e48744a19e3af5ee48313d0ce5fe941d5422fae5705ea922a4aabf94d7800dfa + # via openai +httpx2-jsfetch==1.0 ; python_full_version >= '3.12' and sys_platform == 'emscripten' \ + --hash=sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60 \ + --hash=sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32 + # via httpx2 +idna==3.20 \ + --hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \ + --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c + # via + # anyio + # httpx + # httpx2 +iniconfig==2.3.0 \ + --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ + --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 + # via pytest +jiter==0.17.0 \ + --hash=sha256:00b5a98df3e3a3e8cf7b619f4ac2f8bf975bbf3d95d02c5d17b8dbfe5c8b8245 \ + --hash=sha256:00d783a779c5664e16dbad5e3a3c3a75e128b07dd5f4765159658d9210a50ca5 \ + --hash=sha256:0239520085cac678e77a606fd7e3f1c60c371d719790c5e3807388d3da4354c2 \ + --hash=sha256:02a360707033d8cef53f7f3480817a1489177a259ec6ec01e98c37e0b922ddca \ + --hash=sha256:02adebb7ce6413c44d40af9ad59d1c1cd79630ccdcb6f7bdd2d461e48c03d8f9 \ + --hash=sha256:03e432f226a453851079fb84cd17c6da9991eab723e28d716f14ae3d906e0c12 \ + --hash=sha256:0619d806e260ecf0c2a64521942c94af5d547c9ec99b55ae4f51b538b5576a76 \ + --hash=sha256:073dc68c1a700c8fc480e877864a6b6ffc887533e261f4380c08c16bf09d057a \ + --hash=sha256:0b52d52035b3907c5b1f6277857b29c1cbfc965e24e0f27330dbed83edb591ec \ + --hash=sha256:10c5349312e5cb02b7a21e123a57665afa895953f05bf252a9dd4c13a572b7ab \ + --hash=sha256:10cd64a5720ad7f809ac5466ff1705813f1b6b510f195a73acafba0ac0e1f675 \ + --hash=sha256:10f5558eed511b830488003449d942bd75829ad6257dc58cb9a03e596a7777b1 \ + --hash=sha256:11902505d401691720f5785c15b02204248526edee11b635cd6c40cd52b81599 \ + --hash=sha256:155be7355bdb7ca76ab0961be8982c225f964a5c073a83984183f22391cc29fc \ + --hash=sha256:16dd0c1baf098ae70b8f3616574eb3fedf34e26670b89e16a7e67561f737ed2d \ + --hash=sha256:1b18434638228c0c184281609bf3d9459026a0f1ea48fb76c205e3ef72069caa \ + --hash=sha256:29f49b325e0234e4ad9ecca5b861ffbd09b95ccac9bd46fa55841b6e56eea5fe \ + --hash=sha256:2c45ad7c973ef33fe5114a953377b35a95240f4542c0724d9f781e47dc24bac7 \ + --hash=sha256:300ce01ab0215e3dea4d00090143c909aedc65c0f809b3c07983e1d038f291b9 \ + --hash=sha256:30793a24a31e968969757c9e08d830cbb15a2cd3c4959b4498b38f4b1c2258eb \ + --hash=sha256:30c692d567ba206c7cca38c9d1d0ccc70c9786290173c184d871ca12e9981ed7 \ + --hash=sha256:32aaaa764604496610a3ad2d98503ae88ccb2fbe769e892ff4533e778e85f708 \ + --hash=sha256:362bb47423886d45a9f705d2d9d4008c6eedd4e41eb1bab4e96fb6daa06b33fd \ + --hash=sha256:36ee6e69027396664e59995b9a635a947a5304ee9837279584a0bb8145c8f6b8 \ + --hash=sha256:370d8fe5bf201dc6925e8a84c81ac7291f74d9fd1778234fc79d517064a5c76b \ + --hash=sha256:37150a9e02e869475854fa20b7d0d5e26d18d0f8bc17293999973ff27e99ae7a \ + --hash=sha256:37f33d327900bf2879613b3363fd48df97b4232d0c41f54bcf2e790c2fc40a71 \ + --hash=sha256:3ad556afc289f15d2b181b941982d01f06190863c07440185b9f354e1bd2def3 \ + --hash=sha256:3bf4dc2b84a464117fb097d15a25c58d100d2692888e3b0d92df5b48ed16b7c0 \ + --hash=sha256:3c1a5336c04a41b1f1cf9572e294aec27cc569767ff73de7bf87a91f0bea7cb9 \ + --hash=sha256:3e05f5adbf68c4bd11e1610f394034d984152988e84be6f8314235ce6f2139e5 \ + --hash=sha256:40d2c240f8f80b5b0f201b29f0ae129c81448c60c772227a41747b5e0026f6a2 \ + --hash=sha256:42b0260445251b1bc520a63baa94a32d88e0f931fba234f1764db7feb7c72174 \ + --hash=sha256:454c4997d73cc466c71fd565d91e603b0274e48ea0c6b0b7a7aee6967e4ceb7c \ + --hash=sha256:455e4ab35cb2a4a91a8404e08fd3c621bae433922e59bf1c494fe20a426b013b \ + --hash=sha256:4607ec7d93355fbc25b8dc5189153cf21d66063b9f9cd04dd2774e6e783f9b6a \ + --hash=sha256:470e1b1e4c42f1ead2189166a299691871a2df5056c976e7fb96feafaf5f9d44 \ + --hash=sha256:492f37230bbf9581ab2c17bcda862c249afb9ae2e3ab2dd6db59943bc4cc3153 \ + --hash=sha256:4dfbfe5a6e1e80a7082af559f66386405025ec278833e0c649f69cbc6e1004cc \ + --hash=sha256:4e3f052c671d5f425cca5ea5901cf11a831369fba4a55a3862cab93c323b4c3b \ + --hash=sha256:5078ab00664307fab2019b522a93aeb191122789f085daf5fd9e362154021d4a \ + --hash=sha256:51e1519d676a9f14dad9c2a411170d43b022ddb7989562df4e849b261ce127b2 \ + --hash=sha256:523c499235fb65add25d4bb01b1c4709ce695efdc7deb6c0a7bc515b5c44e0fb \ + --hash=sha256:545c36a0f3b2238c242cc9785439d3242a871b7bc39fe3f441bcaa07bf3aa83e \ + --hash=sha256:55d0e0e613a3f9ad600cf436e0e2b8057d1b52bcf1d91b2d36ac53451231e6a8 \ + --hash=sha256:5888fe5abc1ca2fa834a3e1b4c7ef0dcece286a7d7e95a609ef0934b777b9fc9 \ + --hash=sha256:58df29268a95e910f17db7ec9178eb7f15aa8619aaca3575275c4e6b3f4fe4c5 \ + --hash=sha256:59bddbe6f9ffecc68d641e1e2d619ce64cf8a9e9eeb74e5c518f74fc87abf1b0 \ + --hash=sha256:5a52a430d04225ffde633e6840bf2381d34c019ff98526b5929755b9052fb199 \ + --hash=sha256:5bf350452a43173e69e1fc74847c57a60e3d7515807287f29849baa2a85d8718 \ + --hash=sha256:5c23849235d2142ce444b2b8c6eceee9f82f4cc0bd5c9081602e4155c6197807 \ + --hash=sha256:61aed66ee042b3b49ef85fdf75714234d055d89d8496ac1c6e47f89e7a30d5e4 \ + --hash=sha256:6219adaf59711ba7063a52496e8ec6d3fa3e209d7827d83eee3b2abc780a1744 \ + --hash=sha256:64846211a2debe7c071d2146d2283d2b0c1c93dc8fd5fb7794faac2ca6061b5c \ + --hash=sha256:686c93d86f2b426c803024b805bd161a6cd10e9627c23e901640eab646c0ad8a \ + --hash=sha256:6871973bfbd4408f7f1c632b30bbb5bbd9671c1bc8650af6823e24b7be13709b \ + --hash=sha256:6af5b74073bd25bae695e6d00919f6a9be7ed5a9f8836d981eb1ffe84139e6fb \ + --hash=sha256:6b303d88e6a0bda789ec4b7801c7bad68e27230ba1fe4baffc756d1fbd32dc9d \ + --hash=sha256:6cb41cd1432f1dc19a231cf70b54d42b2c9f05085155859263fce06fa4d41388 \ + --hash=sha256:6cf564d43c4388149ca58ee571d0f5ccf875e20d1fd4662fd94cc0d1ea3b10ef \ + --hash=sha256:6eb6aedeb7352b8f3b6af9cbd67983840165c00428e63f1b420a85885128ea31 \ + --hash=sha256:70f19a2ca8429f91e82eeffb2f51cb87bc2d6e953b009b91a92d29c3a16ccb03 \ + --hash=sha256:71dbd74314c5df52a1bccf7b8bca46d14e943af7a2012e73b23f49977ef194c8 \ + --hash=sha256:73b64e69c4150748e020356d958af94bec33c70a0a93d665cfa8f6d580fe1a63 \ + --hash=sha256:746243a080b4ca790b8499af3d7cf9825d5f5987933950cd818e767ee353d826 \ + --hash=sha256:755079792868ce5d4938e83b91a0939b34fb858a1ca65a104f2d771bea57faa1 \ + --hash=sha256:7573e80232c5bcf80c24c038cf7e53a463f5c3b1dd1dd4109d66304f4dccc233 \ + --hash=sha256:76eb4a5c20e86f9f848286f167024890f2862258a965d254774deb7fc1545ca1 \ + --hash=sha256:77f6aac0137309b31448c1bdcda4c6c77077664a6d018ece8d94019c68a5a5b9 \ + --hash=sha256:785a216bbaf8f15fc974e964ced7322cd3d774bb0e86949edd78c6bffd6ba35b \ + --hash=sha256:7b68d3495d95da120651a5628c7ebadee84ed001a1b76e6afc325c42482f15b5 \ + --hash=sha256:8079849db9a1371bfd90bad088458a8fb836261879df2233cc9632464ecf64e1 \ + --hash=sha256:81c83c0abe614446a283d994d2c07c4f58632dea2cdf66ba9e2921bb8ccd593e \ + --hash=sha256:826871c42cebaae22f0a2b5673a4a1a75c851bb2d13b3c17764a630a6b298984 \ + --hash=sha256:84963d3f395ef5e9a32ce47155e08a7962fa292c159a10cb98b931cef1416925 \ + --hash=sha256:84ac78df457e1ee3f7e733bd114823302ae8c5ad5542d7e6647d92ffaa090a04 \ + --hash=sha256:86d703d9faa1ffc8ae4e9de0fa007712ed2171b5c0d93811a8e2e105ac729b0d \ + --hash=sha256:86f3f9343a288eb85a81ef20a752b2f84564296636db54a9fff0b5c8deaf1df2 \ + --hash=sha256:8adca2e793288e5f1bb29279bb439d0d3cfbb50eddca7e7e6ffd42ff4f482406 \ + --hash=sha256:8c21265b251d99bbb40080d178a8953e35601d3a1564e05c4de4c0d2ca616797 \ + --hash=sha256:8c286860abfe8b100cac1c02e225e5776eb9216edd71ba17cdb237da4af32bc9 \ + --hash=sha256:8f770b0c77e5fac482e1ba03ca1a7e18286bfb213d749932a00a7e4cd5de5e06 \ + --hash=sha256:93946d89fa04d5ba64dd323a8dd8d901676cb8a3c81d99ae4f6c051a9b4c3f2f \ + --hash=sha256:96b8b0c6dc5d78682f54a450785e075aa929cde768304cad363cd4efba5a82ac \ + --hash=sha256:9bd3caac219df476dd0cc3fe01d2f1581ed588906feac767abd9614c1c12f8b3 \ + --hash=sha256:a277f97eba7d66b1ee27eb5dab5b774ff46a10c78d89a1d3dcce04ce1357c8ca \ + --hash=sha256:a3cebb1fe4a1abb00465f3f8a17e09112603e8b7c59e5c3adbcd9f7815a64acd \ + --hash=sha256:ac3c6ee3264d6f5c44c617f90bc7e8b9e1587e7d6708c9d8f811cb65582ee312 \ + --hash=sha256:af2f7501580f274b63c4b2283bc425f5df7edf06ae5b171e5f87d912ff359a20 \ + --hash=sha256:b550585523339b71cb852b811aae49d08d7601ad8ffe9f5dc1562f4c3d22fd87 \ + --hash=sha256:b75f85660108965a94be77911a25a253429307294d9415b3c597118977a614de \ + --hash=sha256:b847b18d066c46b3b7ae49d6c94a7634c5e4a8983146ee25562a092000f5e3ad \ + --hash=sha256:bcc064f99183a9cbe7f26ed648c352031a74145cd61ed75d34632c73eb46a5a8 \ + --hash=sha256:c19b9357309b8cc6de8a48fca8e44a8c9c2feaaa2f5896d037fa505d48fcab80 \ + --hash=sha256:c4289293e5278d9314b00f15c37f2120fa51d3d68565292e715524c750e775a9 \ + --hash=sha256:cfafd7be8b16ceadd298db542cead37cddc211c4c49e04ad2596924df18625b1 \ + --hash=sha256:d0ce4feb52493e3513335b2accdcd75605652e4632772d3c8c2f7b86954d7f39 \ + --hash=sha256:d2c0bf24c72fd0491405dce5d40194f2070e9021ce648c1a1d46234b93d848ff \ + --hash=sha256:d47687806f9c54c84ea38733507081337922beca90ce819c7d852dd485bc0f23 \ + --hash=sha256:d85c558c9f8532bba287a990ac63767c7daf756f0d8c030219f62499b1fa228a \ + --hash=sha256:da139721f4b7cafdbff580a4f511ea24cb91f4909330c6b926a1ca53836c0a59 \ + --hash=sha256:dbbfe4e3c21c8166980cddc5bee1a315df082454f007947dfb6fb73800768165 \ + --hash=sha256:dc0288ce39190ee33fe6e4ec73161eed34e7e2da509b525546ca061778d62b64 \ + --hash=sha256:e088612ff90ebc9247e1a43074b72835804261c47e6a6c01cb3ddcb55360d688 \ + --hash=sha256:e654b6b04e39c9cb19cb8b04c6ddf1f2db07751fa14156413969fd78bad0e5cb \ + --hash=sha256:eaba834b72d573547b9d966465b3394b749d5e14208cc70acb63aca37619ab33 \ + --hash=sha256:eae86b1f027031e39db2e0e9c4842221edb7b8cd474d23f87a79b3bd4b651768 \ + --hash=sha256:eb2295da7c3769f6719b227a237aa6a5cfa6550e478bc838001b592c57e16575 \ + --hash=sha256:ebf918dfd6a74adc1b9ad71f63c4ab00902fcd3b7fd39f2e24d871db8d713b91 \ + --hash=sha256:ec89771f4272b989487a6364e519db6bbaba323e8bbf949ac89a45ea9c18b7a3 \ + --hash=sha256:ed1a24005daac667d577402d75a2922f9775a165b146b883ff1ad3602d8be689 \ + --hash=sha256:efe9f61bb30174d2f5c8396445c360c96c44e78164d0815dfe627ccf57849574 \ + --hash=sha256:f0bc7f684b65bcda9c20434267577db71bf9905ceddd32b60d1d93278d8c8d3a \ + --hash=sha256:f3d7f7b34114f7ddc6d72a8e882d49de636b35d9fd12b4d420d3c5729f6c9812 \ + --hash=sha256:f753eb70b1474a29e635e7542ff7312e6d6b951e0b25e8a2e8c34eeb1ddcd478 \ + --hash=sha256:fa13acf1046f95df808c64b1310705e143fab87aee73ae00cc42d640867fd2c1 \ + --hash=sha256:fd7790aa79c8b518e512ebcdfce9f11d8ef5f30efd43720c8a19a548b39fa489 \ + --hash=sha256:fe15ddf316f1f1f643347d3a474e74ce61880c79a11ec5dca53df20c071bd3e8 \ + --hash=sha256:ffa0380ad091de7d3fc33e17a97ff479851ee18a0a2a3ee56ff3215cdc886656 + # via openai +jsonschema==4.26.0 \ + --hash=sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326 \ + --hash=sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce + # via agentrust-trace +jsonschema-specifications==2025.9.1 \ + --hash=sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe \ + --hash=sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d + # via jsonschema +openai==3.19.2 \ + --hash=sha256:66247fcd07266e72536e90656dc27f3b0bb1e9d8696d4013fc55402c0b96a5c2 \ + --hash=sha256:de185f9834ad064d965ec42bd0766731cf66bceea16a7670294a835d207019e6 + # via -r requirements/../requirements.txt +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via pytest +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via pytest +pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pydantic==2.13.5 \ + --hash=sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73 \ + --hash=sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08 + # via + # agent-manifest + # agentrust-trace + # openai + # weight-custody-manifest +pydantic-core==2.46.5 \ + --hash=sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942 \ + --hash=sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821 \ + --hash=sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5 \ + --hash=sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c \ + --hash=sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184 \ + --hash=sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2 \ + --hash=sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc \ + --hash=sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5 \ + --hash=sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0 \ + --hash=sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931 \ + --hash=sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e \ + --hash=sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25 \ + --hash=sha256:1e5aad1220a1192c42341c8fd4a8686657e73ab2a920c970bdc4de334fe3193d \ + --hash=sha256:200aa3dc9f8d54f0754f43247c0bad0999fdcfbfd2488384dd44f37279271fe6 \ + --hash=sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47 \ + --hash=sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038 \ + --hash=sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8 \ + --hash=sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b \ + --hash=sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a \ + --hash=sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e \ + --hash=sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074 \ + --hash=sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0 \ + --hash=sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433 \ + --hash=sha256:356c8368cbc321050b169595683a2e1d63413b1e0e2868b330af9fc14c616d3f \ + --hash=sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034 \ + --hash=sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21 \ + --hash=sha256:3a3e26b6a8274211bddee2d0e4d0d42778f17a34510f49d2ec44b58abfc41736 \ + --hash=sha256:3aa166e99c4f2985407fb8714aebede877ecb5455cf321b606adca926d30d5a0 \ + --hash=sha256:3d2652072b2d774947ba5cf78a9e59644ac62ee572daf6dd2e1dfe905e15b2b7 \ + --hash=sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c \ + --hash=sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4 \ + --hash=sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c \ + --hash=sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c \ + --hash=sha256:4d44cf99ddebf875f9b68cc267aa684c99b7b44fe63ee1cac4ec163807290069 \ + --hash=sha256:4dedce55295becb61921e386b99d4f2706045306e7fa52249a33004c837379fb \ + --hash=sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061 \ + --hash=sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29 \ + --hash=sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3 \ + --hash=sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa \ + --hash=sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e \ + --hash=sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38 \ + --hash=sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f \ + --hash=sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b \ + --hash=sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8 \ + --hash=sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e \ + --hash=sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c \ + --hash=sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be \ + --hash=sha256:657b40d6240c0a7b6a64b30f22d1e3aa631c7e846c621b0c0f6d1d75e2e15ea6 \ + --hash=sha256:6d30e1a4f138b8951063e9a394752a9179b51da288ffa507b1e659222f4c1793 \ + --hash=sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1 \ + --hash=sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b \ + --hash=sha256:771cf63ae0b1b50dd22e5f3e3549fab5f3f4ff1635d352a9e1a97fe01c7b2e64 \ + --hash=sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f \ + --hash=sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761 \ + --hash=sha256:7b0fc826b16c55e561e5d2a0c5c77b051ba1d92808118c4e4b5390f5e0cf191d \ + --hash=sha256:7c6be839a5a8312626b32029a415644a0846b420bc8b52b95b28cd92da162168 \ + --hash=sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869 \ + --hash=sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111 \ + --hash=sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5 \ + --hash=sha256:850a08d167dde16db8702c274f320c7be9d7da6f6dff2b58b18f9e815bd94f5b \ + --hash=sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7 \ + --hash=sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129 \ + --hash=sha256:895395f8918627b04efb1ad2a4cf605387143300ba03304cd1dfa6d03f5e095e \ + --hash=sha256:8b10e3e8fd7ddc2bd915848a2768e44c15b22936f1cc54c462ad1164deb02655 \ + --hash=sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c \ + --hash=sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec \ + --hash=sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689 \ + --hash=sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f \ + --hash=sha256:978e7b97d4824b5be09c69fb70507cbde3b0323fc147332ca40a94d9a6a0ebbf \ + --hash=sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea \ + --hash=sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9 \ + --hash=sha256:9b68938dd5b0c783d88ff8e2dcc69451b5eb936fe212d516b21b9d5567f6d464 \ + --hash=sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519 \ + --hash=sha256:9f47b8a949e60f027f0aa0a6f6c7b7e9c55cbf4380d10b344e282fa4e7ab1e1b \ + --hash=sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f \ + --hash=sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee \ + --hash=sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a \ + --hash=sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e \ + --hash=sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6 \ + --hash=sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2 \ + --hash=sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f \ + --hash=sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a \ + --hash=sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f \ + --hash=sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a \ + --hash=sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47 \ + --hash=sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda \ + --hash=sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0 \ + --hash=sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4 \ + --hash=sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d \ + --hash=sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3 \ + --hash=sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2 \ + --hash=sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355 \ + --hash=sha256:c3471e5c4a949c26ec00a77f01df59096aa9495877de76fd60a980f8ee6be461 \ + --hash=sha256:c583b927a8838dab890706a6fa7573fbb8b70e24000ef9f7238e2d6f6435a5ed \ + --hash=sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f \ + --hash=sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5 \ + --hash=sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2 \ + --hash=sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829 \ + --hash=sha256:cdc8b74ecc48c0cb1e9607a05ec4e9e88db60a19ffcc9a1d5f9088ede40c8dc0 \ + --hash=sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266 \ + --hash=sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575 \ + --hash=sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290 \ + --hash=sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f \ + --hash=sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62 \ + --hash=sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f \ + --hash=sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a \ + --hash=sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7 \ + --hash=sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed \ + --hash=sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f \ + --hash=sha256:eb7d8d0e5886a89a55d2eef490e272fa965a9d57c6b29a5b5088a7997ec2cad1 \ + --hash=sha256:ecb42011e12ee19cafbc312887cbf3546959fe02fbad44f272d4be5baa997615 \ + --hash=sha256:ef3fbbf161dc9351a2fe0422e51b129f9e97e42385bd0320b309c15f7d287dd8 \ + --hash=sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3 \ + --hash=sha256:f077d0b97ab11fa7dcc633fca53515f290bca8a8a633e966d5b6d1879d9ed01a \ + --hash=sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff \ + --hash=sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084 \ + --hash=sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0 \ + --hash=sha256:fc5d783bd4a2387e97b8a2d5ec781cfb92b3d893bf82370548e99db5915935d3 \ + --hash=sha256:fc8515076c11f3cfdf4fb142dcca0fe384b1230a3b5415458ac84f3e0903ec13 \ + --hash=sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9 + # via pydantic +pygments==2.21.0 \ + --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \ + --hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c + # via pytest +pytest==9.1.1 \ + --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \ + --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c + # via -r requirements/ci.in +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 + # via cmcp-runtime +referencing==0.37.0 \ + --hash=sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231 \ + --hash=sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8 + # via + # agentrust-trace + # jsonschema + # jsonschema-specifications +rfc3986-validator==0.1.1 \ + --hash=sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9 \ + --hash=sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055 + # via agentrust-trace +rfc8785==0.1.4 \ + --hash=sha256:520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48 \ + --hash=sha256:e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da + # via + # agentrust-trace + # cmcp-runtime +rpds-py==2026.6.3 \ + --hash=sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5 \ + --hash=sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680 \ + --hash=sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9 \ + --hash=sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538 \ + --hash=sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804 \ + --hash=sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf \ + --hash=sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4 \ + --hash=sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97 \ + --hash=sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6 \ + --hash=sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96 \ + --hash=sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a \ + --hash=sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187 \ + --hash=sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975 \ + --hash=sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f \ + --hash=sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703 \ + --hash=sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9 \ + --hash=sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127 \ + --hash=sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f \ + --hash=sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa \ + --hash=sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05 \ + --hash=sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171 \ + --hash=sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba \ + --hash=sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c \ + --hash=sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223 \ + --hash=sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4 \ + --hash=sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885 \ + --hash=sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698 \ + --hash=sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f \ + --hash=sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7 \ + --hash=sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed \ + --hash=sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f \ + --hash=sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf \ + --hash=sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e \ + --hash=sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f \ + --hash=sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24 \ + --hash=sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a \ + --hash=sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41 \ + --hash=sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc \ + --hash=sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d \ + --hash=sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146 \ + --hash=sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e \ + --hash=sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e \ + --hash=sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4 \ + --hash=sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12 \ + --hash=sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7 \ + --hash=sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261 \ + --hash=sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6 \ + --hash=sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5 \ + --hash=sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93 \ + --hash=sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7 \ + --hash=sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda \ + --hash=sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8 \ + --hash=sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342 \ + --hash=sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c \ + --hash=sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb \ + --hash=sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0 \ + --hash=sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77 \ + --hash=sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3 \ + --hash=sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885 \ + --hash=sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826 \ + --hash=sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617 \ + --hash=sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb \ + --hash=sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577 \ + --hash=sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80 \ + --hash=sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e \ + --hash=sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945 \ + --hash=sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90 \ + --hash=sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7 \ + --hash=sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0 \ + --hash=sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140 \ + --hash=sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822 \ + --hash=sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba \ + --hash=sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9 \ + --hash=sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4 \ + --hash=sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a \ + --hash=sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8 \ + --hash=sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf \ + --hash=sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4 \ + --hash=sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324 \ + --hash=sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53 \ + --hash=sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b \ + --hash=sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41 \ + --hash=sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9 \ + --hash=sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca \ + --hash=sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1 \ + --hash=sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d \ + --hash=sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690 \ + --hash=sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107 \ + --hash=sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2 \ + --hash=sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76 \ + --hash=sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d \ + --hash=sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af \ + --hash=sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6 \ + --hash=sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db \ + --hash=sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369 \ + --hash=sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd \ + --hash=sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911 \ + --hash=sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504 \ + --hash=sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a \ + --hash=sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9 \ + --hash=sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13 \ + --hash=sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc \ + --hash=sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278 \ + --hash=sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868 \ + --hash=sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2 \ + --hash=sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd \ + --hash=sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4 \ + --hash=sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6 \ + --hash=sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9 \ + --hash=sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00 \ + --hash=sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f \ + --hash=sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e \ + --hash=sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442 \ + --hash=sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da \ + --hash=sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90 \ + --hash=sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef + # via + # jsonschema + # referencing +sniffio==1.3.1 \ + --hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \ + --hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc + # via openai +starlette==1.7.0 \ + --hash=sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e \ + --hash=sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d + # via cmcp-runtime +truststore==0.10.4 ; sys_platform != 'emscripten' \ + --hash=sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301 \ + --hash=sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981 + # via + # httpcore2 + # httpx2 +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # anyio + # httpx2 + # openai + # pydantic + # pydantic-core + # referencing + # starlette + # typing-inspection +typing-inspection==0.4.4 \ + --hash=sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47 \ + --hash=sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147 + # via pydantic +uvicorn==0.54.0 \ + --hash=sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf \ + --hash=sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620 + # via cmcp-runtime +weight-custody-manifest==0.28.4 \ + --hash=sha256:719a2a04c79ef13aba36810bbe9092639c176bb6d25c5860657ff427c9862097 \ + --hash=sha256:7a64113cb188eac13344d4fcdfde7ea76776ae252a32e92beb23bcd0aa4a568b + # via -r requirements/../requirements.txt diff --git a/demos/requirements/fuzz.in b/demos/requirements/fuzz.in new file mode 100644 index 00000000..3d0ca21a --- /dev/null +++ b/demos/requirements/fuzz.in @@ -0,0 +1,7 @@ +# Runtime of the two demo servers the ClusterFuzzLite targets import +# (server/server.py and demo-10-model-gateway/endpoint.py). Atheris itself comes +# with the base-builder-python image. Installed with --require-hashes by +# .clusterfuzzlite/build.sh. Compile with: +# uv pip compile requirements/fuzz.in --generate-hashes --universal \ +# --python-version 3.11 -o requirements/fuzz.txt +starlette>=1.6.0 diff --git a/demos/requirements/fuzz.txt b/demos/requirements/fuzz.txt new file mode 100644 index 00000000..a7767be8 --- /dev/null +++ b/demos/requirements/fuzz.txt @@ -0,0 +1,20 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements/fuzz.in --generate-hashes --universal --python-version 3.11 -o requirements/fuzz.txt +anyio==4.15.1 \ + --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \ + --hash=sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94 + # via starlette +idna==3.20 \ + --hash=sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44 \ + --hash=sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c + # via anyio +starlette==1.7.0 \ + --hash=sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e \ + --hash=sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d + # via -r requirements/fuzz.in +typing-extensions==4.16.0 ; python_full_version < '3.15' \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # anyio + # starlette diff --git a/demos/server/requirements.txt b/demos/server/requirements.txt new file mode 100644 index 00000000..c026499c --- /dev/null +++ b/demos/server/requirements.txt @@ -0,0 +1,2 @@ +starlette>=1.6.0 +uvicorn>=0.53.0 diff --git a/demos/server/server.py b/demos/server/server.py new file mode 100644 index 00000000..394f37f0 --- /dev/null +++ b/demos/server/server.py @@ -0,0 +1,145 @@ +""" +Local MCP filesystem server for cMCP demos. + +Plain HTTP JSON-RPC 2.0 server. cMCP's proxy posts directly to the URL +in the catalog entry, so the server must accept plain POST (not SSE). + +Tools: + write_file(path, content) -- writes a file to ../workspace/ + read_file(path) -- reads a file from ../workspace/ + +Run: + python server.py +""" +import json +import pathlib + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + +WORKSPACE = pathlib.Path(__file__).parent.parent / "workspace" +WORKSPACE.mkdir(exist_ok=True) + + +def _resolve(path: str) -> pathlib.Path: + root = WORKSPACE.resolve() + target = (root / path).resolve() + if not target.is_relative_to(root): + raise ValueError("path traversal not allowed") + return target + + +def _ok(id_, result) -> dict: + return {"jsonrpc": "2.0", "id": id_, "result": {"content": [{"type": "text", "text": str(result)}]}} + + +def _err(id_, code: int, msg: str) -> dict: + return {"jsonrpc": "2.0", "id": id_, "error": {"code": code, "message": msg}} + + +# Loopback names the gateway uses to reach this server (catalog url is +# http://localhost:9001/mcp). Anything else in Host is a DNS-rebinding page. +_LOCAL_HOSTS = {"localhost", "127.0.0.1", "[::1]"} + + +def _host_name(host: str) -> str: + host = host.lower() + if host.startswith("["): + return host.split("]")[0] + "]" + return host.split(":")[0] + + +async def handle(request: Request) -> JSONResponse: + if _host_name(request.headers.get("host", "")) not in _LOCAL_HOSTS: + return JSONResponse(_err(None, -32600, "host not allowed"), status_code=403) + if request.method == "GET": + return JSONResponse({"status": "ok"}) + + # The gateway always posts application/json. Requiring it means a page in + # the presenter's browser cannot write_file here with a text/plain "simple" + # request, which needs no CORS preflight. + ctype = request.headers.get("content-type", "").split(";")[0].strip().lower() + if ctype != "application/json": + return JSONResponse(_err(None, -32600, "Content-Type must be application/json"), + status_code=415) + + try: + body = await request.json() + except Exception: + return JSONResponse(_err(None, -32700, "parse error"), status_code=400) + + if not isinstance(body, dict): + return JSONResponse(_err(None, -32600, "request must be a JSON object"), status_code=400) + id_ = body.get("id") + method = body.get("method", "") + params = body.get("params", {}) + if not isinstance(params, dict) or not isinstance(params.get("arguments", {}), dict): + return JSONResponse(_err(id_, -32600, "params and arguments must be objects"), + status_code=400) + + if method == "initialize": + return JSONResponse({ + "jsonrpc": "2.0", "id": id_, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "demo-filesystem", "version": "0.1.0"}, + }, + }) + + if method == "tools/list": + return JSONResponse({"jsonrpc": "2.0", "id": id_, "result": {"tools": [ + {"name": "write_file", "description": "Write content to a file in the demo workspace", + "inputSchema": {"type": "object", "required": ["path", "content"], + "properties": {"path": {"type": "string"}, "content": {"type": "string"}}}}, + {"name": "read_file", "description": "Read a file from the demo workspace", + "inputSchema": {"type": "object", "required": ["path"], + "properties": {"path": {"type": "string"}}}}, + {"name": "list_dir", "description": "List files in the demo workspace", + "inputSchema": {"type": "object", + "properties": {"path": {"type": "string", "default": ""}}}}, + ]}}) + + if method != "tools/call": + return JSONResponse(_err(id_, -32601, f"method not found: {method}"), status_code=404) + + name = params.get("name", "") + args = params.get("arguments", {}) + + try: + if name == "write_file": + target = _resolve(args["path"]) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(args["content"], encoding="utf-8") + return JSONResponse(_ok(id_, f"Written {len(args['content'])} bytes to {args['path']}")) + elif name == "read_file": + target = _resolve(args["path"]) + return JSONResponse(_ok(id_, target.read_text(encoding="utf-8"))) + elif name == "list_dir": + path = args.get("path", "") + target = _resolve(path) if path else WORKSPACE + entries = sorted(target.iterdir()) + listing = "\n".join(e.name + ("/" if e.is_dir() else "") for e in entries) or "(empty)" + return JSONResponse(_ok(id_, listing)) + else: + return JSONResponse(_err(id_, -32601, f"unknown tool: {name}"), status_code=404) + except KeyError as exc: + return JSONResponse(_err(id_, -32602, f"missing argument: {exc}"), status_code=400) + except ValueError as exc: + return JSONResponse(_err(id_, -32602, str(exc)), status_code=400) + except FileNotFoundError as exc: + return JSONResponse(_err(id_, -32603, str(exc)), status_code=404) + except Exception as exc: + return JSONResponse(_err(id_, -32603, str(exc)), status_code=500) + + +app = Starlette(routes=[ + Route("/mcp", handle, methods=["GET", "POST"]), + Route("/health", handle, methods=["GET"]), +]) + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="localhost", port=9001) diff --git a/demos/tests/test_catalog_definition_drift.py b/demos/tests/test_catalog_definition_drift.py new file mode 100644 index 00000000..56949ab9 --- /dev/null +++ b/demos/tests/test_catalog_definition_drift.py @@ -0,0 +1,94 @@ +"""Static guard against the drift that broke demo 10. + +cMCP hashes description + input schema + output schema on both sides of the +boundary and fail-closes when they differ. A catalog entry that describes an +argument the upstream server does not advertise is a `rug_pull` to the gateway, +so every call comes back 503 and the demo denies everything. Catching that here +costs no ports and no gateway start. +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +from cmcp_runtime.catalog.loader import definition_digest + +ROOT = Path(__file__).resolve().parent.parent + + +def _load(name: str, relative: str): + path = ROOT / relative + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.path.insert(0, str(path.parent)) + try: + spec.loader.exec_module(module) + finally: + sys.path.pop(0) + return module + + +def _catalog_entry(demo: str, tool_name: str) -> dict: + entries = json.loads((ROOT / demo / "catalog.json").read_text(encoding="utf-8")) + for entry in entries: + if entry["tool_name"] == tool_name: + return entry + pytest.fail(f"{demo}/catalog.json has no entry for {tool_name}") + + +def test_model_gateway_catalog_matches_the_advertised_tool(): + entry = _catalog_entry("demo-10-model-gateway", "model.chat_completion") + approved = entry["approved_definition"] + tool = _load("demo_model_server", "demo-10-model-gateway/model_server.py").TOOL + + assert definition_digest( + approved["description"], + approved.get("input_schema"), + approved.get("output_schema"), + ) == definition_digest( + str(tool.get("description", "")), + tool.get("inputSchema") or {}, + tool.get("outputSchema"), + ), ( + "catalog.json and model_server.py describe different tools. The gateway " + "reads that as a rug pull and denies every call." + ) + + +def test_model_gateway_endpoint_arguments_are_all_in_the_schema(): + """Every argument the endpoint sends must be an approved property. + + This is the specific gap that shipped: the endpoint sent + ``contains_identifiers``, the Cedar policy read it, and neither schema + declared it. + """ + entry = _catalog_entry("demo-10-model-gateway", "model.chat_completion") + properties = set(entry["approved_definition"]["input_schema"]["properties"]) + + source = (ROOT / "demo-10-model-gateway" / "endpoint.py").read_text(encoding="utf-8") + start = source.index(" args = {") + block = source[start:source.index("\n }", start)] + sent = {line.split('"')[1] for line in block.splitlines() if line.strip().startswith('"')} + + assert sent, "could not parse the endpoint's argument block" + assert sent <= properties, f"endpoint sends undeclared arguments: {sorted(sent - properties)}" + + +def test_stored_definition_hashes_are_current(): + """A hand-edited schema with a stale definition_hash fails at catalog load.""" + from cmcp_runtime.catalog.loader import _compute_definition_hash + + for catalog in sorted(ROOT.glob("demo-*/catalog.json")): + for entry in json.loads(catalog.read_text(encoding="utf-8")): + if "definition_hash" not in entry: + continue + computed = _compute_definition_hash(entry["approved_definition"]) + assert entry["definition_hash"] == computed, ( + f"{catalog.parent.name}/{entry['tool_name']}: stale definition_hash" + ) diff --git a/demos/tests/test_path_containment.py b/demos/tests/test_path_containment.py new file mode 100644 index 00000000..cd81fa79 --- /dev/null +++ b/demos/tests/test_path_containment.py @@ -0,0 +1,96 @@ +"""Negative tests for sibling-prefix traversal in the demo servers, and for the +web console's tokenless-gateway env handling.""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent + + +def _load(name: str, relative: str): + path = ROOT / relative + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.path.insert(0, str(path.parent)) + try: + spec.loader.exec_module(module) + finally: + sys.path.pop(0) + return module + + +def test_filesystem_server_rejects_sibling_with_workspace_prefix(tmp_path, monkeypatch): + server = _load("demo_filesystem_server", "server/server.py") + workspace = tmp_path / "workspace" + sibling = tmp_path / "workspace-secret" + workspace.mkdir() + sibling.mkdir() + (sibling / "secret.txt").write_text("not in the workspace", encoding="utf-8") + monkeypatch.setattr(server, "WORKSPACE", workspace) + + with pytest.raises(ValueError, match="traversal"): + server._resolve("../workspace-secret/secret.txt") + + +def test_filesystem_server_accepts_actual_descendant(tmp_path, monkeypatch): + server = _load("demo_filesystem_server_descendant", "server/server.py") + workspace = tmp_path / "workspace" + workspace.mkdir() + monkeypatch.setattr(server, "WORKSPACE", workspace) + + assert server._resolve("reports/result.txt") == workspace / "reports" / "result.txt" + + +def test_web_server_refuses_sibling_with_web_prefix(tmp_path, monkeypatch): + webserver = _load("demo_webserver", "web-console/webserver.py") + web = tmp_path / "web" + sibling = tmp_path / "web-backup" + web.mkdir() + sibling.mkdir() + (sibling / "secret.txt").write_text("not a public asset", encoding="utf-8") + monkeypatch.setattr(webserver, "WEB", web) + response = {} + handler = object.__new__(webserver.Handler) + handler._send = lambda status, body, content_type: response.update( + status=status, body=body, content_type=content_type + ) + + handler._serve("../web-backup/secret.txt") + + assert response == {"status": 404, "body": "not found", "content_type": "text/plain"} + + +def test_web_console_gateway_env_drops_inherited_bearer_token(): + """The web console's gateway is tokenless (the vendored credit_risk_agent.py + never sends an Authorization header). If CMCP_BEARER_TOKEN is inherited + from the shell -- e.g. left set from the terminal demos' README step -- + cmcp starts requiring it anyway and every /api/run call fails with + MISSING_BEARER_TOKEN. The gateway env must always drop it.""" + run = _load("demo_web_console_run", "web-console/run.py") + base_env = {"PATH": "/usr/bin", "CMCP_BEARER_TOKEN": "demo-token"} + + env = run._gateway_env(base_env) + + assert "CMCP_BEARER_TOKEN" not in env + assert env["CMCP_DEV_MODE"] == "1" + assert env["PATH"] == "/usr/bin" + + +def test_web_console_tampered_gateway_env_drops_inherited_bearer_token(): + """Same requirement for the tampered second gateway (demo 2's policy-swap + story inside the console) -- it shares the same tokenless config.""" + policy_variants = _load( + "demo_web_console_policy_variants", "web-console/policy_variants.py") + base_env = {"PATH": "/usr/bin", "CMCP_BEARER_TOKEN": "demo-token"} + + env = policy_variants.gateway_env(base_env) + + assert "CMCP_BEARER_TOKEN" not in env + assert env["CMCP_DEV_MODE"] == "1" + assert env["PATH"] == "/usr/bin" diff --git a/demos/tests/test_request_hardening.py b/demos/tests/test_request_hardening.py new file mode 100644 index 00000000..05a32ce3 --- /dev/null +++ b/demos/tests/test_request_hardening.py @@ -0,0 +1,250 @@ +"""Request handling on the demo servers that a browser on the same machine can reach. + +Every server here binds loopback, but loopback is not a trust boundary against a +web page: any site the presenter has open can send a cross-origin "simple" POST +(text/plain, no preflight) to localhost, and a DNS-rebinding page can make that +request same-origin. These tests pin the refusals, and the 400s for bodies that +used to escape as unhandled exceptions. +""" + +from __future__ import annotations + +import http.client +import importlib.util +import json +import sys +import threading +from http.server import ThreadingHTTPServer +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parent.parent + + +def _load(name: str, relative: str): + path = ROOT / relative + spec = importlib.util.spec_from_file_location(name, path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.path.insert(0, str(path.parent)) + try: + spec.loader.exec_module(module) + finally: + sys.path.pop(0) + return module + + +# --- web console (http.server) ------------------------------------------------ + + +@pytest.fixture() +def console(monkeypatch): + webserver = _load("hardening_webserver", "web-console/webserver.py") + runs = [] + + def fake_run_agent(scenario, gateway=webserver.GATEWAY): + runs.append(scenario) + return {"scenario": scenario, "steps": [], "claim": None, "error": None} + + monkeypatch.setattr(webserver, "_run_agent", fake_run_agent) + monkeypatch.setattr(webserver, "_policy_hashes", + lambda: {"approved": "sha256:a", "tampered": "sha256:t"}) + srv = ThreadingHTTPServer(("127.0.0.1", 0), webserver.Handler) + thread = threading.Thread(target=srv.serve_forever, daemon=True) + thread.start() + try: + yield srv.server_address[1], runs + finally: + srv.shutdown() + srv.server_close() + + +def _request(port, method, path, body=None, headers=None): + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + try: + conn.request(method, path, body=body, headers=headers or {}) + resp = conn.getresponse() + return resp.status, resp.read() + finally: + conn.close() + + +def _json_headers(port, **extra): + return {"Content-Type": "application/json", "Host": f"localhost:{port}", **extra} + + +def test_console_same_origin_run_still_works(console): + port, runs = console + status, _ = _request(port, "POST", "/api/run", + json.dumps({"scenario": "clean"}), + _json_headers(port, Origin=f"http://localhost:{port}")) + assert status == 200 + assert runs == ["clean"] + + +def test_console_refuses_cross_origin_simple_post(console): + """text/plain is a CORS-safelisted type, so a foreign page's fetch() sends + it without a preflight. The server parsed it as JSON anyway and ran the + agent (and could start the tampered gateway).""" + port, runs = console + status, _ = _request(port, "POST", "/api/run", + json.dumps({"scenario": "clean", "variant": "approved"}), + {"Content-Type": "text/plain", "Host": f"localhost:{port}", + "Origin": "https://attacker.example"}) + assert status in (403, 415) + assert runs == [] + + +def test_console_refuses_foreign_origin_even_with_json(console): + port, runs = console + status, _ = _request(port, "POST", "/api/run", json.dumps({"scenario": "clean"}), + _json_headers(port, Origin="https://attacker.example")) + assert status == 403 + assert runs == [] + + +def test_console_refuses_rebound_host(console): + """A DNS-rebinding page reaches the server with its own name in Host.""" + port, runs = console + status, _ = _request(port, "GET", "/api/context", + headers={"Host": f"attacker.example:{port}"}) + assert status == 403 + status, _ = _request(port, "POST", "/api/run", json.dumps({"scenario": "clean"}), + {"Content-Type": "application/json", + "Host": f"attacker.example:{port}"}) + assert status == 403 + assert runs == [] + + +@pytest.mark.parametrize("body", [b"not json", b"[1, 2]", b"\"clean\"", b"\xff\xfe", + pytest.param(b"[" * 50000, id="nested-past-recursion-limit")]) +def test_console_malformed_body_is_a_400(console, body): + """These used to raise inside do_POST, so the client saw a dropped + connection instead of an error.""" + port, runs = console + status, _ = _request(port, "POST", "/api/run", body, _json_headers(port)) + assert status == 400 + assert runs == [] + + +@pytest.mark.parametrize("length", ["-1", "abc", str(10 * 1024 * 1024)]) +def test_console_bad_or_oversized_content_length(console, length): + port, runs = console + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=10) + try: + conn.putrequest("POST", "/api/run", skip_host=True) + conn.putheader("Host", f"localhost:{port}") + conn.putheader("Content-Type", "application/json") + conn.putheader("Content-Length", length) + conn.endheaders() + resp = conn.getresponse() + assert resp.status in (400, 413) + finally: + conn.close() + assert runs == [] + + +# --- demo filesystem MCP server (starlette) ---------------------------------- + + +@pytest.fixture() +def fs_server(tmp_path, monkeypatch): + from starlette.testclient import TestClient + + server = _load("hardening_fs_server", "server/server.py") + monkeypatch.setattr(server, "WORKSPACE", tmp_path) + return TestClient(server.app, base_url="http://localhost:9001"), tmp_path + + +def _write_call(path="note.txt", content="hi"): + return {"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "write_file", "arguments": {"path": path, "content": content}}} + + +def test_fs_server_gateway_style_write_still_works(fs_server): + client, ws = fs_server + r = client.post("/mcp", json=_write_call()) + assert r.status_code == 200 + assert (ws / "note.txt").read_text(encoding="utf-8") == "hi" + + +def test_fs_server_refuses_cross_origin_text_plain_write(fs_server): + client, ws = fs_server + r = client.post("/mcp", content=json.dumps(_write_call()), + headers={"Content-Type": "text/plain"}) + assert r.status_code == 415 + assert not (ws / "note.txt").exists() + + +def test_fs_server_refuses_rebound_host(fs_server): + client, ws = fs_server + r = client.post("/mcp", json=_write_call(), + headers={"Host": "attacker.example:9001"}) + assert r.status_code == 403 + assert not (ws / "note.txt").exists() + + +@pytest.mark.parametrize("body", [[1, 2], "x", 3, + {"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": ["write_file"]}, + {"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "write_file", "arguments": ["a"]}}]) +def test_fs_server_non_object_request_is_invalid_request(fs_server, body): + client, _ = fs_server + r = client.post("/mcp", json=body) + assert r.status_code == 400 + assert r.json()["error"]["code"] == -32600 + + +# --- demo 10 OpenAI-compatible endpoint -------------------------------------- + + +@pytest.fixture() +def endpoint(monkeypatch): + from starlette.testclient import TestClient + + ep = _load("hardening_endpoint", "demo-10-model-gateway/endpoint.py") + forwarded = [] + + def fake_post(url, payload, timeout=20): + forwarded.append(payload) + text = json.dumps({"content": "ok"}) + return {"result": {"content": [{"type": "text", "text": text}]}}, 200 + + monkeypatch.setattr(ep, "_post", fake_post) + return TestClient(ep.app, base_url="http://127.0.0.1:8500"), forwarded + + +def _chat(model="frontier-large"): + return {"model": model, "messages": [{"role": "user", "content": "hello"}]} + + +@pytest.mark.parametrize("data_class", ["public", "pii", "confidential", "hipaa_phi"]) +def test_endpoint_known_classes_are_forwarded(endpoint, data_class): + client, forwarded = endpoint + r = client.post("/v1/chat/completions", json=_chat(), + headers={"x-data-class": data_class}) + assert r.status_code == 200 + assert forwarded[-1]["params"]["arguments"]["data_class"] == data_class + + +@pytest.mark.parametrize("data_class", ["Confidential", "HIPAA_PHI", "phi", "secret", " pii"]) +def test_endpoint_unknown_class_is_refused_not_forwarded(endpoint, data_class): + """The Cedar bundle forbids by listing classes, so any label outside the + list matched no forbid and went out of region under the permit.""" + client, forwarded = endpoint + r = client.post("/v1/chat/completions", json=_chat(), + headers={"x-data-class": data_class}) + assert r.status_code == 400 + assert forwarded == [] + + +@pytest.mark.parametrize("body", [b"not json", b"[]", b"{\"messages\": \"hi\"}", + b"{\"messages\": [{\"role\": \"user\", \"content\": 7}]}"]) +def test_endpoint_malformed_body_is_a_400(endpoint, body): + client, forwarded = endpoint + r = client.post("/v1/chat/completions", content=body, + headers={"Content-Type": "application/json"}) + assert r.status_code == 400 + assert forwarded == [] diff --git a/demos/tests/test_shared_example.py b/demos/tests/test_shared_example.py new file mode 100644 index 00000000..1c195d04 --- /dev/null +++ b/demos/tests/test_shared_example.py @@ -0,0 +1,70 @@ +"""The browser console must work from the combined checkout without Git fetches.""" + +import importlib.util +import sys +from pathlib import Path +from unittest.mock import Mock + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +CONSOLE = ROOT / "demos" / "web-console" +EXAMPLE = ROOT / "examples" / "financial-services" + + +def load_console(filename): + spec = importlib.util.spec_from_file_location("consolidated_" + filename[:-3], CONSOLE / filename) + module = importlib.util.module_from_spec(spec) + sys.path.insert(0, str(CONSOLE)) + try: + spec.loader.exec_module(module) + finally: + sys.path.pop(0) + return module + + +@pytest.mark.parametrize("filename", ["run.py", "webserver.py", "policy_variants.py"]) +def test_console_uses_the_shared_example(filename): + module = load_console(filename) + assert module.EXAMPLE == EXAMPLE + for asset in ["agent/credit_risk_agent.py", "server/mock_mcp_server.py", "catalog.json", "policy/allow.cedar"]: + assert (module.EXAMPLE / asset).is_file(), asset + + +def test_missing_example_fails_without_fetching(monkeypatch, tmp_path): + module = load_console("run.py") + monkeypatch.setattr(module, "EXAMPLE", tmp_path) + fetch = Mock(side_effect=AssertionError("the combined checkout must not fetch a submodule")) + monkeypatch.setattr(module.subprocess, "run", fetch) + with pytest.raises(SystemExit, match="shared example missing"): + module._ensure_example() + fetch.assert_not_called() + + +def test_launcher_hides_children_and_can_skip_browser(monkeypatch, tmp_path): + module = load_console("run.py") + monkeypatch.setattr(module, "HERE", tmp_path) + monkeypatch.setattr(module, "CHILD_FLAGS", 0x08000000) + monkeypatch.setattr(module, "_assert_port_free", lambda *args: None) + monkeypatch.setattr(module, "_wait_for_port", lambda *args: None) + monkeypatch.setattr(module, "_write_gateway_config", lambda: None) + monkeypatch.setattr(module, "_find_cmcp", lambda: "cmcp") + monkeypatch.setattr(sys, "argv", ["run.py", "--no-browser"]) + child = Mock() + child.poll.return_value = 0 + spawn = Mock(return_value=child) + browser = Mock() + monkeypatch.setattr(module.subprocess, "Popen", spawn) + monkeypatch.setattr(module.webbrowser, "open", browser) + module.main() + assert spawn.call_count == 3 + assert all(call.kwargs["creationflags"] == 0x08000000 for call in spawn.call_args_list) + browser.assert_not_called() + + +def test_policy_variants_use_the_shared_bundle(monkeypatch, tmp_path): + module = load_console("policy_variants.py") + monkeypatch.setattr(module, "TAMPERED_DIR", tmp_path / "tampered") + tampered, edits = module.build_tampered() + assert edits + assert module.bundle_hash(module.APPROVED_DIR) != module.bundle_hash(tampered) diff --git a/demos/web-console/README.md b/demos/web-console/README.md new file mode 100644 index 00000000..88f8ecee --- /dev/null +++ b/demos/web-console/README.md @@ -0,0 +1,89 @@ +# Web console -- cMCP credit-risk assessment + +A browser console for showing cMCP on a screen. It runs the **real +financial-services example** from `examples/financial-services` in this repository (an EU corporate +credit-risk agent) unmodified, behind a small web UI. Nothing here is a copy: +the tool server, Cedar policy, catalog, and agent all come from the shared example directory. + +``` +pip install cmcp-runtime httpx +git clone https://github.com/agentrust-io/integrations.git +cd integrations +python demos/web-console/run.py # opens http://localhost:8000 +``` + +`run.py` starts three local processes and opens the console: + +- the example's mock EU credit-risk MCP server on `:8080` +- the cMCP gateway on `:8443` (`CMCP_DEV_MODE=1`, software-only TEE) +- this demo's web server on `:8000` + +Ctrl+C stops all three. Add `--no-browser` to print the URL without opening a browser. + +## What it shows + +An AI agent runs a six-step credit assessment through the gateway. It reads the +client's filings, screens for sanctions, pulls a credit-bureau report, +aggregates group exposure, and runs the PD/LGD model -- then passes that outcome +into the write. Cedar enforces the bank's controls **on the result**, so the +write is only recorded if the assessment passes them. Three obligors: + +| Scenario | Obligor | Write | Blocked by | +|---|---|---|---| +| Performing | Rheintal Präzisionstechnik GmbH, €250k | allowed | -- | +| Concentration breach | Nordwind Logistik AG, €750k | blocked | CRR Art. 395 / EBA/GL/2020/06 | +| Sanctions / impaired | Meridian Trading DMCC, €200k | blocked | IFRS 9 (stage 3) | + +Each deny carries the regulation and reason back as structured advice. Close the +session for the signed `RuntimeClaim`, then verify it offline with `cmcp verify` +(reads `partially_verified` in software-only mode; `verified` on real TDX / +SEV-SNP). + +The **Policy** tab shows the Cedar bundle the gateway loaded and maps each +guardrail to the control it enforces. **What this shows** explains the four +properties the demo proves. + +## Approved vs tampered policy + +The **Policy bundle** selector on the Assessment tab puts the policy-swap story +(previously the separate terminal `demo-02-policy-swap`) in the same view. Pick +**Tampered** and the console runs the agent against a second gateway holding an +edited copy of the bundle: the delegated-authority ceiling raised from €500k to +€5m, and the concentration guardrail inverted so it never fires. The tampered +copy is generated from the approved bundle at runtime, so it cannot drift from +the example. + +Run **Nordwind Logistik AG (€750k)** under each: + +| Bundle | Step 6, the write | Verify against the approved hash | +|---|---|---| +| Approved | `403 denied` — EBA/GL/2020/06 | `policy_bundle.hash` **PASS** | +| Tampered | `200 allow` — the write lands | `policy_bundle.hash` **FAIL** | + +That is the whole argument for the record: enforcement did exactly what the +loaded policy said, so the record has to prove *which* policy was loaded. +`cmcp verify` is always pinned to the approved bundle hash — a verifier does not +get to move the goalposts after the fact. + +The gateway measures the bundle at startup, so the tampered bundle needs its own +process. It starts lazily on `:8444` the first time you select **Tampered**, and +stops when the console exits. The approved gateway on `:8443` is untouched. + +## How it fits together + +The browser never talks to the gateway directly. `webserver.py` serves the UI +and, on **Run**, executes the example's own agent +(`examples/financial-services/agent/credit_risk_agent.py`) as a +subprocess against the gateway, then parses its output into the step timeline. +`run.py` supplies only a loopback gateway config that points at the example's +policy and catalog where they live -- it does not copy them. Changes to the shared example are exercised by the console in the same checkout. + +## Files + +``` +web-console/ + run.py launcher (example server + gateway + web server) + webserver.py static UI + /api (runs the example agent, runs cmcp verify) + web/ index.html, app.js, styles.css +../../examples/financial-services/ the shared scenario +``` diff --git a/demos/web-console/policy_variants.py b/demos/web-console/policy_variants.py new file mode 100644 index 00000000..0ee289c0 --- /dev/null +++ b/demos/web-console/policy_variants.py @@ -0,0 +1,182 @@ +"""Approved vs tampered Cedar bundles, and a second gateway to serve the latter. + +Demo 2 (policy swap) used to be a separate terminal demo. This puts it in the +same console, so one view tells the whole story: enforcement, then tampering, +then the record refusing to agree with the tamper. + +How it works +------------ +The gateway reads its policy bundle once, at startup, and measures the bundle +hash into the attestation report before any code runs -- so a policy swap means +a *different gateway process*. Rather than restarting the approved gateway +(which the launcher owns), this module starts a SECOND gateway on its own port +with a tampered copy of the bundle. Both stay up; the console picks which one +to run the agent against. + +The tampered bundle is generated from the approved one at runtime, so it can +never drift from the example. Two edits, both the kind an operator who wanted a +loan booked would actually make: + + 1. the delegated-authority ceiling is raised from EUR 500k to EUR 5m, so a + EUR 750k facility no longer needs a human decision-maker + 2. the single-obligor concentration guardrail is inverted so it never fires + +Everything else in the bundle is byte-identical. That is the point: two small +edits, a completely different decision, and a bundle hash that cannot be made +to match the approved one. +""" +from __future__ import annotations + +import json +import os +import pathlib +import shutil +import subprocess +import sys +import time + +from cmcp_runtime.policy.bundle import _canonical_bundle_hash + +HERE = pathlib.Path(__file__).parent.resolve() +CHILD_FLAGS = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 +EXAMPLE = HERE.parents[1] / "examples" / "financial-services" +WORKSPACE = HERE.parent / "workspace" + +APPROVED_DIR = EXAMPLE / "policy" +TAMPERED_DIR = WORKSPACE / "policy-tampered" + +TAMPERED_PORT = 8444 +TAMPERED_URL = f"http://localhost:{TAMPERED_PORT}" + +# (what to find, what to replace it with, what to call it in the UI) +EDITS = [ + ("context.arguments.amount_eur > 500000", + "context.arguments.amount_eur > 5000000", + "delegated-authority ceiling raised from EUR 500k to EUR 5m"), + ("@delegated_authority_limit_eur(\"500000\")", + "@delegated_authority_limit_eur(\"5000000\")", + "the annotation was updated to match, so the edit looks deliberate"), + ("context.arguments.breaches_concentration_limit == true", + "context.arguments.breaches_concentration_limit == false", + "single-obligor concentration guardrail inverted so it never fires"), +] + + +def bundle_hash(policy_dir: pathlib.Path) -> str: + """Bundle hash exactly as the runtime computes it, so the value shown in the + console is the value that lands in the signed record.""" + manifest = json.loads((policy_dir / "manifest.json").read_text(encoding="utf-8")) + policy_files = { + p.relative_to(policy_dir).as_posix(): p.read_text(encoding="utf-8") + for p in sorted(policy_dir.glob("**/*.cedar")) + } + schema = (policy_dir / "schema.cedarschema").read_text(encoding="utf-8") + return _canonical_bundle_hash(manifest, policy_files, schema) + + +def build_tampered() -> tuple[pathlib.Path, list[str]]: + """Copy the approved bundle and apply the edits. Returns the dir and the + human-readable list of what was changed.""" + if TAMPERED_DIR.exists(): + shutil.rmtree(TAMPERED_DIR) + shutil.copytree(APPROVED_DIR, TAMPERED_DIR) + + target = TAMPERED_DIR / "allow.cedar" + text = target.read_text(encoding="utf-8") + applied = [] + for find, replace, label in EDITS: + if find not in text: + raise RuntimeError( + f"tamper edit no longer matches the example policy: {find!r}. " + "The shared example changed; update EDITS in policy_variants.py." + ) + text = text.replace(find, replace) + applied.append(label) + target.write_text(text, encoding="utf-8") + return TAMPERED_DIR, applied + + +def gateway_env(base_env: dict) -> dict: + """Env for a gateway subprocess: software-only TEE, and no inherited + bearer token. Same tokenless loopback config as web-console/run.py's + approved gateway -- an inherited CMCP_BEARER_TOKEN (e.g. left set from the + terminal demos) would make cmcp require auth the agent never sends.""" + env = dict(base_env) + env["CMCP_DEV_MODE"] = "1" + env.pop("CMCP_BEARER_TOKEN", None) + return env + + +def write_config(policy_dir: pathlib.Path, port: int, audit_name: str) -> pathlib.Path: + WORKSPACE.mkdir(exist_ok=True) + cfg = WORKSPACE / f"fs-gateway-{audit_name}.yaml" + cfg.write_text( + f"policy_bundle_path: {policy_dir.as_posix()}\n" + f"catalog_path: {(EXAMPLE / 'catalog.json').as_posix()}\n" + f"listen_addr: 127.0.0.1:{port}\n" + f"max_response_size_bytes: 2097152\n" + f"audit_db_path: {(WORKSPACE / f'fs-audit-{audit_name}.db').as_posix()}\n" + f"attestation:\n" + f" provider: auto\n" + f" enforcement_mode: enforcing\n" + ) + return cfg + + +def _find_cmcp() -> str: + found = shutil.which("cmcp") + if found: + return found + import sysconfig + for scripts in (pathlib.Path(sys.executable).parent, + pathlib.Path(sysconfig.get_path("scripts")), + pathlib.Path(sysconfig.get_path("scripts", "nt_user"))): + for name in ("cmcp.exe", "cmcp"): + if (scripts / name).exists(): + return str(scripts / name) + return "cmcp" + + +class TamperedGateway: + """Lazily-started second gateway serving the tampered bundle.""" + + def __init__(self) -> None: + self.proc: subprocess.Popen | None = None + self.applied: list[str] = [] + self.log = None + + def ensure(self, env: dict) -> str: + """Start it if it is not already up. Returns the gateway URL.""" + if self.proc is not None and self.proc.poll() is None: + return TAMPERED_URL + policy_dir, self.applied = build_tampered() + cfg = write_config(policy_dir, TAMPERED_PORT, "tampered") + self.log = open(HERE / "cmcp-tampered.log", "w") + self.proc = subprocess.Popen( + [_find_cmcp(), "start", "--config", str(cfg)], + stdout=self.log, stderr=self.log, env=gateway_env(env), creationflags=CHILD_FLAGS, + ) + # the gateway measures the bundle before it binds; give it a moment + for _ in range(40): + time.sleep(0.25) + if self.proc.poll() is not None: + raise RuntimeError( + "tampered gateway exited on startup -- see cmcp-tampered.log" + ) + try: + import socket + with socket.create_connection(("127.0.0.1", TAMPERED_PORT), 0.25): + return TAMPERED_URL + except OSError: + continue + raise RuntimeError("tampered gateway did not come up on :%d" % TAMPERED_PORT) + + def stop(self) -> None: + if self.proc is not None and self.proc.poll() is None: + self.proc.terminate() + try: + self.proc.wait(timeout=5) + except subprocess.TimeoutExpired: + self.proc.kill() + if self.log is not None: + self.log.close() diff --git a/demos/web-console/run.py b/demos/web-console/run.py new file mode 100644 index 00000000..0f62a0d1 --- /dev/null +++ b/demos/web-console/run.py @@ -0,0 +1,200 @@ +"""cMCP browser console -- one-command launcher (cross-platform). + +Runs the shared examples/financial-services scenario behind a small web console. +The tool server, Cedar policy, catalog, and agent run directly from this checkout. This launcher only supplies a loopback gateway +config (tokenless dev mode binds to loopback) and starts: + + - the example's mock EU credit-risk MCP server on :8080 + - the cMCP gateway on :8443 (CMCP_DEV_MODE=1, software-only TEE) + - this demo's web server on :8000 + +Then it opens http://localhost:8000. Ctrl+C stops everything. + + python web-console/run.py # from repo root + python run.py # from the web-console directory +""" +import argparse +import os +import pathlib +import shutil +import socket +import subprocess +import sys +import time +import webbrowser + +sys.stdout.reconfigure(line_buffering=True) + +HERE = pathlib.Path(__file__).parent.resolve() +REPO_ROOT = HERE.parent +EXAMPLE = HERE.parents[1] / "examples" / "financial-services" +WORKSPACE = REPO_ROOT / "workspace" +GATEWAY_CFG = WORKSPACE / "fs-gateway.yaml" +PORT = os.environ.get("WEB_CONSOLE_PORT", "8000") +CHILD_FLAGS = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 + + +def _find_cmcp() -> str: + found = shutil.which("cmcp") + if found: + return found + import sysconfig + for scripts in (pathlib.Path(sys.executable).parent, + pathlib.Path(sysconfig.get_path("scripts"))): + for name in ("cmcp.exe", "cmcp"): + if (scripts / name).exists(): + return str(scripts / name) + sys.exit("cmcp not found. Run: pip install cmcp-runtime httpx") + + +def _log_tail(what: str, lines: int = 15) -> str: + """Return the tail of the log for `what`, or "" if there is nothing to show. + + A service that fails to bind has already written the reason to its log. The + launcher used to point at the file and leave; printing the tail turns a 60 + second wait plus a scavenger hunt into the actual error. + """ + name = "cmcp.log" if "cMCP" in what or "cmcp" in what else "server.log" + path = pathlib.Path(__file__).parent.resolve() / name + try: + tail = path.read_text(encoding="utf-8", errors="replace").splitlines()[-lines:] + except OSError: + return "" + if not tail: + return "" + body = "\n".join(" " + line for line in tail) + return f"\n\nLast {len(tail)} lines of {name}:\n{body}" + + +def _wait_for_port(port: int, what: str, process=None, timeout: float = 60.0) -> None: + """Block until something is listening on 127.0.0.1:port. + + A fixed sleep used to be enough; cMCP Runtime now does attestation and audit + setup before it binds, so the console raced startup and the first assessment + failed on a refused connection. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if process is not None and process.poll() is not None: + sys.exit(f"{what} exited with code {process.returncode} before listening on :{port}." + + _log_tail(what)) + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + return + except OSError: + time.sleep(0.25) + sys.exit(f"{what} did not start listening on :{port} within {timeout:.0f}s." + + _log_tail(what)) + + +def _assert_port_free(port: int, what: str) -> None: + """Refuse to start if something already owns the port. + + _wait_for_port() returns as soon as *anything* answers, so a gateway left + over from a demo run satisfies it instantly while this console's own + gateway dies on a bind error in cmcp.log. The console then scores every + assessment against the other gateway's policy bundle and still prints + plausible allow/deny lines. Same guard the terminal demos already carry. + """ + try: + with socket.create_connection(("127.0.0.1", port), timeout=1): + pass + except OSError: + return + sys.exit( + f"Port {port} is already in use, so {what} cannot start and this console " + f"would be scored against whatever is already listening. Stop it first " + f"(a cMCP gateway left over from another demo is the usual cause), then " + f"re-run." + ) + + +def _ensure_example() -> None: + if not (EXAMPLE / "agent" / "credit_risk_agent.py").is_file(): + sys.exit("shared example missing. Use a complete integrations checkout.") + + +def _gateway_env(base_env: dict) -> dict: + """Env for the approved gateway subprocess: software-only TEE, and no + inherited bearer token -- see the CMCP_BEARER_TOKEN.pop comment at the + call site for why.""" + env = dict(base_env) + env["CMCP_DEV_MODE"] = "1" + # loopback demo, tokenless: the shared credit_risk_agent.py never sends + # an Authorization header. If CMCP_BEARER_TOKEN is inherited from the + # shell -- e.g. left set from the terminal demos' README step -- cmcp + # starts requiring it anyway and every /api/run call fails. + env.pop("CMCP_BEARER_TOKEN", None) + return env + + +def _write_gateway_config() -> None: + # A loopback config pointing at the shared example's own policy and + # catalog. We do not copy them -- we reference them where they live. + WORKSPACE.mkdir(exist_ok=True) + GATEWAY_CFG.write_text( + f"policy_bundle_path: {(EXAMPLE / 'policy').as_posix()}\n" + f"catalog_path: {(EXAMPLE / 'catalog.json').as_posix()}\n" + f"listen_addr: 127.0.0.1:8443\n" + f"max_response_size_bytes: 2097152\n" + f"audit_db_path: {(WORKSPACE / 'fs-audit.db').as_posix()}\n" + f"attestation:\n" + f" provider: auto\n" + f" enforcement_mode: enforcing\n" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--no-browser", action="store_true", help="print the URL without opening a browser") + args = parser.parse_args() + _ensure_example() + _assert_port_free(8080, "the EU credit-risk MCP server") + _assert_port_free(8443, "the cMCP gateway") + _assert_port_free(int(PORT), "the web console") + _write_gateway_config() + server_log = open(HERE / "server.log", "w") + cmcp_log = open(HERE / "cmcp.log", "w") + procs = [] + try: + print("-- EU credit-risk MCP server on :8080", flush=True) + procs.append(subprocess.Popen( + [sys.executable, str(EXAMPLE / "server" / "mock_mcp_server.py")], + stdout=server_log, stderr=server_log, creationflags=CHILD_FLAGS)) + _wait_for_port(8080, "EU credit-risk MCP server", procs[-1]) + + print("-- cMCP gateway on :8443 (CMCP_DEV_MODE=1)", flush=True) + procs.append(subprocess.Popen( + [_find_cmcp(), "start", "--config", str(GATEWAY_CFG)], + stdout=cmcp_log, stderr=cmcp_log, env=_gateway_env(os.environ), creationflags=CHILD_FLAGS)) + _wait_for_port(8443, "cMCP gateway", procs[-1]) + + print(f"-- web console on http://localhost:{PORT}", flush=True) + web = subprocess.Popen([sys.executable, str(HERE / "webserver.py")], env=os.environ.copy(), creationflags=CHILD_FLAGS) + procs.append(web) + _wait_for_port(int(PORT), "web console", web) + + url = f"http://localhost:{PORT}" + print(f"\nOpen {url}. Ctrl+C to stop.\n", flush=True) + if not args.no_browser: + try: + webbrowser.open(url) + except Exception: + pass + web.wait() + except KeyboardInterrupt: + print("\nstopping...", flush=True) + finally: + for p in reversed(procs): + if p and p.poll() is None: + p.terminate() + try: + p.wait(timeout=5) + except subprocess.TimeoutExpired: + p.kill() + server_log.close() + cmcp_log.close() + + +if __name__ == "__main__": + main() diff --git a/demos/web-console/web/app.js b/demos/web-console/web/app.js new file mode 100644 index 00000000..cd27a941 --- /dev/null +++ b/demos/web-console/web/app.js @@ -0,0 +1,167 @@ +"use strict"; +const $ = (s) => document.querySelector(s); +const api = async (path, body) => (await fetch(path, body + ? { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) } + : {})).json(); +// Quotes too: several values land inside attribute values, not only text. +const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + +let CTX = null; +let selected = "clean"; +let variant = "approved"; // which Cedar bundle the gateway loaded +let reasonMap = {}; +let lastRun = null; + +function showView(name) { + document.querySelectorAll(".navitem").forEach((b) => b.classList.toggle("active", b.dataset.view === name)); + document.querySelectorAll(".view").forEach((v) => v.classList.toggle("active", v.id === "view-" + name)); +} +document.querySelectorAll(".navitem").forEach((b) => b.addEventListener("click", () => showView(b.dataset.view))); + +async function boot() { + CTX = await api("/api/context"); + $("#gw-status").textContent = "gateway " + CTX.gateway.replace(/^https?:\/\//, ""); + CTX.guardrails.forEach((g) => (reasonMap[g.reason] = g)); + + // scenarios + $("#scenarios").innerHTML = CTX.scenarios.map((s) => ` + `).join(""); + document.querySelectorAll(".scn").forEach((c) => c.addEventListener("click", () => { + selected = c.dataset.id; + document.querySelectorAll(".scn").forEach((x) => x.classList.toggle("sel", x === c)); + })); + + // policy variant -- approved bundle, or the one an operator quietly edited + renderVariant(); + document.querySelectorAll(".var").forEach((b) => b.addEventListener("click", () => { + variant = b.dataset.variant; + renderVariant(); + })); + $("#tamper-edits").innerHTML = (CTX.tamper_edits || []) + .map((e) => `
  • ${esc(e)}
  • `).join(""); + + // guardrails + policy + $("#guardrails").innerHTML = CTX.guardrails.map((g) => + `
    ${esc(g.regulation)}${esc(g.plain)}
    `).join(""); + $("#policy").textContent = CTX.policy.trim(); + + // tools + $("#tools").innerHTML = CTX.tools.map((t) => + `
    ${esc(t.tool_name)}${esc(t.compliance_domain)}${esc(t.description)}
    `).join(""); +} + +function renderVariant() { + document.querySelectorAll(".var").forEach((b) => + b.classList.toggle("sel", b.dataset.variant === variant)); + const h = (CTX.policy_hashes || {})[variant] || ""; + $("#variant-hash").textContent = h; + $("#tamper-note").className = variant === "tampered" ? "tamper show" : "tamper"; +} + +async function run() { + const btn = $("#run"); + btn.disabled = true; btn.textContent = "Running…"; $("#run-hint").textContent = ""; + $("#timeline").innerHTML = ""; $("#summary").className = "summary hide"; + try { + const r = await api("/api/run", { scenario: selected, variant }); + lastRun = r; + renderRun(r); + } catch (e) { + $("#run-hint").textContent = "run failed -- is the gateway up?"; + } finally { + btn.disabled = false; btn.textContent = "Run assessment"; + } +} + +function renderRun(r) { + const steps = r.steps || []; + const sum = $("#summary"); + + // A crashed or partial run is an error, not a pass. Inferring "allowed" from + // the absence of a denied write used to render a failure as a green success. + if (r.error) { + sum.className = "summary deny"; + sum.innerHTML = `Run failed. ${esc(r.error.split("\n")[0])} +
    ${esc(r.error.split("\n").slice(1).join("\n"))}
    `; + $("#timeline").innerHTML = ""; + return; + } + + const write = steps.find((s) => s.tool && s.tool.endsWith("risk_report_writer")); + const denied = write && write.decision === "deny"; + // A write that only succeeded because the bundle was edited is not a success. + // Colour it as the warning it is, not the green of a clean pass. + const tamperedAllow = !denied && r.variant === "tampered"; + sum.className = "summary " + (denied ? "deny" : tamperedAllow ? "tampered" : "allow"); + if (denied) { + const reason = write.advice.reason || ""; + const g = reasonMap[reason]; + sum.innerHTML = `Write blocked. ${esc(g ? g.plain : reason)} + ${esc(write.advice.regulation || (g && g.regulation) || "")} +
    The risk report was not written to core banking.
    `; + } else if (r.variant === "tampered") { + sum.innerHTML = `Write allowed — under the tampered bundle. + The same facility that the approved policy refused was just written to core banking. +
    The enforcement worked exactly as the loaded policy told it to. That is why the record has to prove which policy was loaded.
    `; + } else { + sum.innerHTML = `Assessment recorded. All six steps passed policy; the report was written to core banking under Cedar enforcement.`; + } + + $("#timeline").innerHTML = steps.map((s) => { + const dec = s.decision || "allow"; + const adv = (dec === "deny" && (s.advice.regulation || s.advice.reason)) ? ` +
    ${esc((reasonMap[s.advice.reason] || {}).plain || s.advice.reason || "denied by policy")} + ${s.advice.regulation ? `${esc(s.advice.regulation)}` : ""}
    ` : ""; + return `
  • + ${s.n}/6 + ${esc(s.tool)} + ${s.note ? `
    ${esc(s.note)}
    ` : ""}${adv}
    + ${dec === "deny" ? "403 denied" : "200 allow"} +
  • `; + }).join(""); + // staggered reveal + document.querySelectorAll("#timeline .tl").forEach((el, i) => setTimeout(() => el.classList.add("in"), 90 + i * 130)); + + // record view + if (r.claim) { + $("#record").textContent = JSON.stringify(r.claim, null, 2); + $("#verify").disabled = false; + $("#rec-hint").textContent = ""; + } +} + +async function verify() { + // Always verify against the APPROVED bundle hash. A verifier that approved + // one policy does not get to move the goalposts afterwards. + const v = await api("/api/verify", { pinned: true }); + if (v.error) { $("#verify-result").innerHTML = `
    ${esc(v.error)}
    `; return; } + $("#verify-checks").innerHTML = (v.checks || []).map((c) => { + const p = c.status === "PASS"; + return `
    ${p ? "✓" : "✗"}${esc(c.name)}${c.status}
    `; + }).join(""); + + const tampered = lastRun && lastRun.variant === "tampered"; + let html = ""; + if (tampered) { + html += `
    policy_bundle.hash — FAIL
    + This record came from the tampered bundle. A verifier holding the approved + hash rejects it, and no edit to the record can make the two agree.
    `; + } + if (v.result) { + const ok = v.result.detail === "verified"; + html += `
    ${esc(v.result.detail)} — software-only run; on real TDX / SEV-SNP the hardware check verifies too.
    `; + } + if (v.pinned_hash) { + html += `
    pinned to approved bundle
    ${esc(v.pinned_hash)}
    `; + } + $("#verify-result").innerHTML = html; +} + +$("#run").addEventListener("click", run); +$("#verify").addEventListener("click", verify); +boot(); diff --git a/demos/web-console/web/index.html b/demos/web-console/web/index.html new file mode 100644 index 00000000..b8e76772 --- /dev/null +++ b/demos/web-console/web/index.html @@ -0,0 +1,121 @@ + + + + + + cMCP console -- credit-risk assessment + + + +
    + + +
    + +
    +
    +
    +

    Corporate credit-risk assessment

    +

    An AI agent runs a six-step assessment through the cMCP gateway. Cedar enforces the bank's controls, and the write is only recorded if the assessment result passes them.

    +
    +
    + +
    + +
    + Policy bundle loaded by the gateway +
    + + + +
    +
    + What the operator changed +
      + Everything else in the bundle is byte-identical — and the hash still moves. +
      +
      + +
      + + Pick an obligor, then run. +
      + +
      +
        +
        + + +
        +
        +

        Policy

        +

        The Cedar bundle the gateway loaded. Its hash is measured into the hardware attestation at startup, so the policy that ran is provably the one that was approved. Each guardrail carries the control it enforces.

        +
        +

        Guardrails on the write

        +
        +

        allow.cedar

        +
        
        +      
        + + +
        +
        +

        Trust record

        +

        The signed RuntimeClaim produced when the session closes: which tools ran, which were denied, the policy hash, and an Ed25519 signature. Verify it offline -- no gateway, no network, no trust in the operator.

        +
        +
        + + Run an assessment to produce a record. +
        +
        +
        +

        cmcp verify

        +
        +
        +
        +
        +

        RuntimeClaim

        +
        Run an assessment first.
        +
        +
        +
        + + +
        +
        +

        What this shows

        +

        A real corporate credit-risk agent, running unmodified from the examples/financial-services repository, governed by cMCP.

        +
        +
        +

        Proof of which tools ran

        The gateway records every tool call in a hash-chained audit log and seals the session into a signed Trust Record. An auditor can verify exactly which tools ran, in what order, and what was denied -- without trusting the agent process.

        +

        Policy as machine-readable compliance

        The bank's controls are written directly in Cedar. Each block returns the regulation and the reason as structured advice, so the caller knows why a write was refused and who must review it.

        +

        Controls fire on the result

        The agent screens the client, pulls a bureau report, aggregates exposure, and runs the model, then passes that outcome into the write. The guardrails act on the real assessment -- CDD status, IFRS 9 stage, concentration, facility size -- not on the request.

        +

        Attestation-gated data

        A Cedar rule forbids confidential (MNPI) tools unless the runtime presents attestation evidence. On real TDX or SEV-SNP the Trust Record's hardware check verifies too; in dev mode it reads partially_verified.

        +
        +

        The six tools

        +
        +

        Source: examples/financial-services (shared example). The obligors are fictional German Mittelstand companies with format-valid LEIs and IBANs; the guardrails and regulations are real.

        +
        +
        +
        + + + diff --git a/demos/web-console/web/styles.css b/demos/web-console/web/styles.css new file mode 100644 index 00000000..b7968b31 --- /dev/null +++ b/demos/web-console/web/styles.css @@ -0,0 +1,154 @@ +:root{ + --bg:#0a0f0e; --panel:#101917; --panel2:#0d1513; --raise:#14201d; + --line:rgba(255,255,255,.08); --line2:rgba(255,255,255,.15); + --ink:#e9f1ee; --ink2:#aebbb6; --mut:#788681; + --green:#10ba87; --blue:#00b0ff; --umber:#e1765a; --sand:#e2c99e; + --sans:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,sans-serif; + --mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace; +} +*{box-sizing:border-box} +html,body{margin:0;height:100%} +body{background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.55} +code{font-family:var(--mono);font-size:.92em} +button{font-family:inherit;cursor:pointer} +.hide{display:none!important} +.mut{color:var(--mut)} + +.app{display:grid;grid-template-columns:236px 1fr;height:100%} + +/* sidebar */ +.nav{background:var(--panel2);border-right:1px solid var(--line);display:flex;flex-direction:column;padding:18px 12px} +.brand{padding:6px 10px 20px} +.wordmark{display:block;font-weight:800;letter-spacing:.14em;font-size:18px;color:var(--green)} +.brand-sub{display:block;font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--mut);margin-top:3px} +.navitem{display:flex;align-items:center;gap:10px;width:100%;text-align:left;background:none;border:none; + color:var(--ink2);padding:10px 11px;border-radius:8px;font-size:13.5px;margin-bottom:2px} +.navitem .gi{width:7px;height:7px;border-radius:2px;background:var(--mut);flex:0 0 auto} +.navitem:hover{background:var(--raise);color:var(--ink)} +.navitem.active{background:var(--raise);color:var(--ink)} +.navitem.active .gi{background:var(--green)} +.nav-foot{margin-top:auto;padding:12px 11px 4px;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:6px} +.stat{display:flex;align-items:center;gap:8px;font-family:var(--mono);font-size:11px;color:var(--ink2)} +.stat .d{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 0 3px rgba(16,186,135,.2)} +.stat.mut{color:var(--mut)} + +/* main */ +.main{overflow-y:auto} +.view{display:none;padding:30px clamp(20px,4vw,56px) 60px;max-width:1100px} +.view.active{display:block} +.vhead{margin-bottom:24px} +.vhead h1{font-size:23px;font-weight:700;margin:0 0 8px;letter-spacing:-.01em} +.vhead p{margin:0;color:var(--ink2);max-width:74ch} +h2.sec{font-size:11px;text-transform:uppercase;letter-spacing:.13em;color:var(--mut);font-weight:600;margin:26px 0 12px} + +/* scenarios */ +.scenarios{display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px} +.scn{text-align:left;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:14px 15px;transition:.15s} +.scn:hover{border-color:var(--line2)} +.scn.sel{border-color:var(--green);box-shadow:0 0 0 1px var(--green) inset} +.scn .label{font-weight:600;font-size:13.5px;margin-bottom:8px} +.scn .obligor{font-size:12.5px;color:var(--ink2);line-height:1.4} +.scn .amt{font-family:var(--mono);font-size:12px;color:var(--ink);margin-top:6px} +.scn .exp{display:inline-block;margin-top:11px;font-size:10.5px;letter-spacing:.04em;padding:2px 8px;border-radius:5px;text-transform:uppercase} +.scn .exp.allow{background:rgba(16,186,135,.16);color:var(--green)} +.scn .exp.deny{background:rgba(225,118,90,.16);color:var(--umber)} + +.runbar{display:flex;align-items:center;gap:14px;margin-bottom:18px} +.primary{background:var(--green);color:#052a20;border:none;border-radius:9px;padding:10px 18px;font-size:13.5px;font-weight:700} +.primary:hover{filter:brightness(1.06)} +.primary:disabled{opacity:.45;cursor:default;filter:none} +button:focus-visible{outline:2px solid var(--green);outline-offset:2px} + +/* summary banner */ +.summary{border-radius:12px;padding:15px 18px;margin-bottom:18px;border:1px solid var(--line);font-size:14px} +.summary.allow{background:rgba(16,186,135,.1);border-color:rgba(16,186,135,.4)} +.summary.deny{background:rgba(225,118,90,.1);border-color:rgba(225,118,90,.4)} +.summary .st{font-weight:700} +.summary.tampered{background:rgba(237,174,90,.1);border-color:rgba(237,174,90,.5)} +.summary.allow .st{color:var(--green)} .summary.deny .st{color:var(--umber)} +.summary.tampered .st{color:#edae5a} +.summary .reg{font-family:var(--mono);font-size:11px;margin-left:8px;padding:2px 7px;border-radius:5px;background:rgba(0,0,0,.25)} + +/* timeline */ +.timeline{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px} +.tl{display:flex;gap:14px;align-items:flex-start;background:var(--panel);border:1px solid var(--line);border-radius:11px; + padding:13px 15px;opacity:0;transform:translateY(6px);transition:.3s} +.tl.in{opacity:1;transform:none} +.tl .num{font-family:var(--mono);font-size:11px;color:var(--mut);width:26px;flex:0 0 auto;padding-top:2px} +.tl .body{flex:1;min-width:0} +.tl .toolname{font-family:var(--mono);font-size:13px;color:var(--ink)} +.tl .note{font-size:12.5px;color:var(--ink2);margin-top:3px} +.tl .adv{margin-top:8px;font-size:12px;color:var(--umber);display:flex;flex-wrap:wrap;gap:6px 14px} +.tl .adv .reg{font-family:var(--mono);font-size:11px;background:rgba(225,118,90,.14);color:var(--umber);padding:2px 7px;border-radius:5px} +.tl .pill{font-size:11px;font-weight:700;letter-spacing:.04em;padding:4px 10px;border-radius:6px;flex:0 0 auto} +.tl.allow .pill{background:rgba(16,186,135,.16);color:var(--green)} +.tl.deny .pill{background:rgba(225,118,90,.16);color:var(--umber)} +.tl.deny{border-color:rgba(225,118,90,.3)} + +/* policy */ +.code{font-family:var(--mono);font-size:12.5px;line-height:1.65;color:var(--ink2);white-space:pre;overflow-x:auto; + background:var(--panel2);border:1px solid var(--line);border-radius:12px;padding:16px 18px;margin:0;max-height:60vh} +.guardrails{display:flex;flex-direction:column;gap:8px} +.gr{display:flex;gap:14px;align-items:baseline;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 15px} +.gr .reg{font-family:var(--mono);font-size:11px;color:var(--sand);flex:0 0 auto;width:190px} +.gr .plain{color:var(--ink2);font-size:13px} + +/* trust record */ +.rec-actions{display:flex;align-items:center;gap:14px;margin-bottom:18px} +.rec-grid{display:grid;grid-template-columns:1fr 1fr;gap:22px} +.checks{display:flex;flex-direction:column;gap:6px;margin-bottom:14px} +.check{display:flex;align-items:center;gap:12px;font-family:var(--mono);font-size:12.5px;padding:9px 12px;border:1px solid var(--line);border-radius:8px;background:var(--panel)} +.check .box{width:18px;height:18px;border-radius:5px;display:grid;place-items:center;font-size:12px;font-weight:700;flex:0 0 auto} +.check.pass .box{background:rgba(16,186,135,.18);color:var(--green)} +.check.fail .box{background:rgba(225,118,90,.18);color:var(--umber)} +.check .name{flex:1;color:var(--ink2)} +.check .st{font-size:10px;font-weight:700;letter-spacing:.06em} +.check.pass .st{color:var(--green)} .check.fail .st{color:var(--umber)} +.rslt{padding:12px 15px;border-radius:9px;font-family:var(--mono);font-size:12.5px;border:1px solid var(--line2);background:var(--panel)} +.rslt.partial{border-color:rgba(226,201,158,.4)} .rslt.partial b{color:var(--sand)} +.rslt.verified{border-color:rgba(16,186,135,.4)} .rslt.verified b{color:var(--green)} + +/* policy variant -- approved bundle vs the one an operator edited */ +.variants{margin:18px 0 6px} +.vlabel{display:block;font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--mut);margin-bottom:8px} +.vrow{display:flex;align-items:center;gap:10px;flex-wrap:wrap} +.var{display:flex;flex-direction:column;gap:2px;text-align:left;cursor:pointer; + background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:9px 14px;color:var(--ink2)} +.var:hover{border-color:var(--line2)} +.var .vname{font-size:13.5px;font-weight:650;color:var(--ink)} +.var .vdesc{font-size:11.5px;color:var(--mut)} +.var.sel{border-color:var(--green)} +.var.sel .vname{color:var(--green)} +.var[data-variant="tampered"].sel{border-color:var(--umber)} +.var[data-variant="tampered"].sel .vname{color:var(--umber)} +.vhash{font-size:11.5px;color:var(--mut);word-break:break-all;flex:1 1 260px} +.tamper{display:none;margin-top:12px;background:var(--panel);border:1px solid rgba(225,118,90,.4); + border-radius:9px;padding:12px 15px} +.tamper.show{display:block} +.tamper .tt{display:block;font-size:12px;font-weight:650;color:var(--umber);margin-bottom:6px} +.tamper ul{margin:0 0 6px;padding-left:18px;font-size:12.5px;color:var(--ink2)} +.tamper li{margin:2px 0} +.tamper .mut{font-size:12px} +.rslt.tampered{border-color:rgba(225,118,90,.5)} .rslt.tampered b{color:var(--umber)} +.rslt.pinned{color:var(--mut)} .rslt.pinned code{color:var(--ink2);word-break:break-all} + +/* about */ +.cards{display:grid;grid-template-columns:1fr 1fr;gap:14px} +.pt{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:16px 18px} +.pt h3{margin:0 0 7px;font-size:14.5px;font-weight:650} +.pt p{margin:0;color:var(--ink2);font-size:13px} +.tools{display:flex;flex-direction:column;gap:7px} +.trow{display:grid;grid-template-columns:230px 130px 1fr;gap:14px;align-items:baseline;background:var(--panel);border:1px solid var(--line);border-radius:9px;padding:10px 14px;font-size:12.5px} +.trow code{color:var(--ink)} +.trow .dom{font-family:var(--mono);font-size:11px} +.trow .dom.mnpi,.trow .dom.pci_data{color:var(--umber)} .trow .dom.pii{color:var(--sand)} .trow .dom.internal{color:var(--blue)} +.trow .desc{color:var(--mut)} +.foot-note{margin-top:20px;color:var(--mut);font-size:12px} + +@media (max-width:900px){ + .app{grid-template-columns:1fr} + .nav{flex-direction:row;flex-wrap:wrap;height:auto;align-items:center} + .nav nav{display:flex;gap:4px} + .nav-foot{margin:0 0 0 auto;border:none;padding:0} + .scenarios,.rec-grid,.cards{grid-template-columns:1fr} +} diff --git a/demos/web-console/webserver.py b/demos/web-console/webserver.py new file mode 100644 index 00000000..f6cbf4f7 --- /dev/null +++ b/demos/web-console/webserver.py @@ -0,0 +1,319 @@ +"""Web server for the cMCP financial-services console. + +Serves the UI in web/ and a small JSON API. The API runs the real example +agent (examples/financial-services) as a subprocess against the running +gateway and parses its output into structured steps; it also runs cmcp verify +on the signed record. The browser never talks to the gateway directly. + + python webserver.py # serves on http://localhost:8000 +""" +import json +import os +import pathlib +import re +import shutil +import subprocess +import sys +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +import policy_variants + +HERE = pathlib.Path(__file__).parent.resolve() +WEB = HERE / "web" +CHILD_FLAGS = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 +EXAMPLE = HERE.parents[1] / "examples" / "financial-services" +AGENT = EXAMPLE / "agent" / "credit_risk_agent.py" +WORKSPACE = HERE.parent / "workspace" +CLAIM_FILE = WORKSPACE / "fs-claim.json" + +GATEWAY = os.environ.get("CMCP_GATEWAY_URL", "http://localhost:8443") +PORT = int(os.environ.get("WEB_CONSOLE_PORT", "8000")) + +TAMPERED = policy_variants.TamperedGateway() +_HASHES: dict[str, str] = {} + + +def _policy_hashes() -> dict[str, str]: + """Approved and tampered bundle hashes, computed the way the runtime does. + Cached: the approved bundle is read-only and the tampered one is generated + deterministically from it.""" + if not _HASHES: + _HASHES["approved"] = policy_variants.bundle_hash(policy_variants.APPROVED_DIR) + tampered_dir, _ = policy_variants.build_tampered() + _HASHES["tampered"] = policy_variants.bundle_hash(tampered_dir) + return _HASHES + +# Names the console answers to. Anything else in Host is a rebinding attempt. +LOOPBACK_NAMES = ("localhost", "127.0.0.1", "[::1]") +# The UI posts a few dozen bytes; nothing legitimate comes close to this. +MAX_BODY = 64 * 1024 + +_CT = {".html": "text/html", ".js": "text/javascript", ".css": "text/css", + ".svg": "image/svg+xml", ".json": "application/json"} + +SCENARIOS = [ + {"id": "clean", "label": "Performing obligor", + "obligor": "Rheintal Präzisionstechnik GmbH", "amount": "€250,000", + "expect": "Write allowed", "outcome": "allow"}, + {"id": "large-exposure", "label": "Concentration breach", + "obligor": "Nordwind Logistik AG", "amount": "€750,000", + "expect": "Write blocked", "outcome": "deny"}, + {"id": "sanctions-hit", "label": "Sanctions / impaired", + "obligor": "Meridian Trading DMCC", "amount": "€200,000", + "expect": "Write blocked", "outcome": "deny"}, +] + +# Plain-English gloss for the guardrails in policy/allow.cedar, keyed by the +# @reason annotation the runtime returns on deny. +GUARDRAILS = [ + {"reason": "cdd-clearance-required", "regulation": "EU AML Regulation 2024/1624", + "plain": "No assessment is written until customer due diligence clears."}, + {"reason": "human-review-required", "regulation": "EBA/GL/2020/06", + "plain": "Facilities above the €500k delegated authority need a human decision-maker."}, + {"reason": "concentration-limit-breached", "regulation": "CRR Art. 395", + "plain": "The facility must not breach the single-obligor concentration limit."}, + {"reason": "ifrs9-stage-3-credit-impaired", "regulation": "IFRS 9", + "plain": "Credit-impaired (stage 3) obligors cannot be auto-approved."}, + {"reason": "attested-runtime-required", "regulation": "DORA Art. 9", + "plain": "Confidential financial data only flows through an attested runtime."}, +] + + +def _find_cmcp() -> str: + found = shutil.which("cmcp") + if found: + return found + import sysconfig + for scripts in (pathlib.Path(sys.executable).parent, + pathlib.Path(sysconfig.get_path("scripts"))): + for name in ("cmcp.exe", "cmcp"): + if (scripts / name).exists(): + return str(scripts / name) + return "cmcp" + + +def _run_agent(scenario: str, gateway: str = GATEWAY): + """Run the real example agent and parse its output into steps + claim.""" + env = os.environ.copy() + env["PYTHONIOENCODING"] = "utf-8" + proc = subprocess.run( + [sys.executable, str(AGENT), "--scenario", scenario, "--gateway", gateway], + capture_output=True, text=True, encoding="utf-8", env=env, timeout=60, creationflags=CHILD_FLAGS) + out = (proc.stdout or "") + "\n" + (proc.stderr or "") + + claim = None + marker = out.find("TRACE Trust Record") + body = out + if marker != -1: + rest = out[marker:] + brace = rest.find("{") + if brace != -1: + try: + claim, _ = json.JSONDecoder().raw_decode(rest[brace:]) + except json.JSONDecodeError: + claim = None + body = out[:marker] + + steps, cur = [], None + for line in body.splitlines(): + m = re.match(r"\s*\[(\d)/6\]\s+(\S+)", line) + if m: + cur = {"n": m.group(1), "tool": m.group(2), "decision": None, "note": "", "advice": {}} + steps.append(cur) + continue + if cur is None: + continue + m = re.search(r"->\s*decision:\s*(allow|deny)(?:\s*\(([^)]+)\))?(.*)", line) + if m: + cur["decision"] = m.group(1) + if m.group(2): + cur["deny_code"] = m.group(2) + note = (m.group(3) or "").strip() + if note: + cur["note"] = note + continue + m = re.match(r"\s+(reason|regulation|delegated_authority_limit_eur|error_code):\s*(.+)", line) + if m: + cur["advice"][m.group(1)] = m.group(2).strip() + + if claim: + WORKSPACE.mkdir(exist_ok=True) + CLAIM_FILE.write_text(json.dumps(claim, indent=2)) + + # A run that crashed or produced no steps must not be reported as a clean + # pass. Previously the UI inferred "allowed" from the absence of a denied + # write, so a failed run rendered as a green success banner. + error = None + if proc.returncode != 0 or not steps: + tail = "\n".join(l for l in out.strip().splitlines()[-6:] if l.strip()) + error = (f"the example agent exited with code {proc.returncode} and " + f"produced {len(steps)} of 6 steps.\n{tail}") + return {"scenario": scenario, "steps": steps, "claim": claim, "error": error} + + +def _verify(pinned_hash: str | None = None): + """Verify the last record. When pinned_hash is given, the verifier is held + to the policy bundle it approved -- which is what makes a tampered run + detectable rather than merely different.""" + if not CLAIM_FILE.exists(): + return {"error": "run an assessment first"}, 409 + env = os.environ.copy() + env["CMCP_DEV_MODE"] = "1" + cmd = [_find_cmcp(), "verify", str(CLAIM_FILE)] + if pinned_hash: + cmd += ["--policy-hash", pinned_hash] + proc = subprocess.run(cmd, capture_output=True, text=True, env=env, creationflags=CHILD_FLAGS) + raw = (proc.stdout or "") + (proc.stderr or "") + checks = [{"name": m.group(1).strip(), "status": m.group(2)} + for m in re.finditer(r"\[cmcp verify\]\s+(.+?)\s{2,}(PASS|FAIL)", raw)] + result = None + m = re.search(r"RESULT:\s*(\w+)\s*\(([^)]+)\)", raw) + if m: + result = {"status": m.group(1), "detail": m.group(2)} + return {"raw": raw.strip(), "checks": checks, "result": result}, 200 + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def _send(self, status, body, ct="application/json"): + if isinstance(body, (dict, list)): + body = json.dumps(body).encode() + elif isinstance(body, str): + body = body.encode() + self.send_response(status) + self.send_header("Content-Type", ct) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if not self._local_request(): + return + if self.path in ("/", "/index.html"): + return self._serve("index.html") + if self.path.startswith("/web/"): + return self._serve(self.path[len("/web/"):]) + if self.path == "/api/context": + catalog = json.loads((EXAMPLE / "catalog.json").read_text(encoding="utf-8")) + tools = [{"tool_name": c["tool_name"], "compliance_domain": c.get("compliance_domain"), + "sensitivity_level": c.get("sensitivity_level"), + "description": c["approved_definition"]["description"]} for c in catalog] + return self._send(200, { + "scenarios": SCENARIOS, "tools": tools, "guardrails": GUARDRAILS, + "policy": (EXAMPLE / "policy" / "allow.cedar").read_text(encoding="utf-8"), + "gateway": GATEWAY, + "policy_hashes": _policy_hashes(), + "tamper_edits": [label for _, _, label in policy_variants.EDITS], + }) + return self._send(404, {"error": "not found"}) + + def _local_request(self) -> bool: + """Refuse requests a browser page from another site could have sent. + + Loopback is not a boundary against the presenter's own browser: any open + page can POST a CORS-safelisted text/plain body here without a preflight, + and a DNS-rebinding page arrives with its own name in Host. /api/run + spawns the agent and can start a second gateway, so both are refused. + """ + port = self.server.server_address[1] + allowed = {f"{h}:{port}" for h in LOOPBACK_NAMES} + if self.headers.get("Host", "").lower() not in allowed: + self._send(403, {"error": "host not allowed"}) + return False + origin = self.headers.get("Origin") + if origin is not None and origin.lower() not in {f"http://{a}" for a in allowed}: + self._send(403, {"error": "cross-origin request refused"}) + return False + return True + + def _read_json(self): + """The request body as a JSON object, or None after sending the error.""" + ctype = self.headers.get("Content-Type", "").split(";")[0].strip().lower() + if ctype != "application/json": + self._send(415, {"error": "Content-Type must be application/json"}) + return None + try: + length = int(self.headers.get("Content-Length", "0")) + except ValueError: + length = -1 + if length < 0: + self._send(400, {"error": "bad Content-Length"}) + return None + if length > MAX_BODY: + self._send(413, {"error": "request body too large"}) + return None + try: + payload = json.loads(self.rfile.read(length) or b"{}") + except (ValueError, RecursionError): # bad UTF-8, bad JSON, or nested past the stack + payload = None + if not isinstance(payload, dict): + self._send(400, {"error": "request body must be a JSON object"}) + return None + return payload + + def do_POST(self): + if not self._local_request(): + return + payload = self._read_json() + if payload is None: + return + if self.path == "/api/run": + scenario = payload.get("scenario", "clean") + variant = payload.get("variant", "approved") + if scenario not in {s["id"] for s in SCENARIOS}: + return self._send(400, {"error": "unknown scenario"}) + if variant not in ("approved", "tampered"): + return self._send(400, {"error": "unknown policy variant"}) + try: + gateway, applied = GATEWAY, [] + if variant == "tampered": + gateway = TAMPERED.ensure(os.environ.copy()) + applied = TAMPERED.applied + body = _run_agent(scenario, gateway) + body["variant"] = variant + body["tamper_edits"] = applied + body["policy_hash"] = _policy_hashes()[variant] + return self._send(200, body) + except subprocess.TimeoutExpired: + return self._send(504, {"error": "agent run timed out"}) + except RuntimeError as exc: + return self._send(500, {"error": str(exc)}) + if self.path == "/api/verify": + # The verifier is pinned to the APPROVED bundle: that is the whole + # point. A record from the tampered gateway fails this check. + pinned = _policy_hashes()["approved"] if payload.get("pinned", True) else None + body, status = _verify(pinned) + if isinstance(body, dict): + body["pinned_hash"] = pinned + return self._send(status, body) + return self._send(404, {"error": "not found"}) + + def _serve(self, rel): + root = WEB.resolve() + requested = pathlib.PurePosixPath(str(rel)) + if requested.is_absolute() or ".." in requested.parts: + return self._send(404, "not found", "text/plain") + target = (root / pathlib.Path(*requested.parts)).resolve() + if not target.is_relative_to(root) or not target.is_file(): + return self._send(404, "not found", "text/plain") + self._send(200, target.read_bytes(), _CT.get(target.suffix, "application/octet-stream")) + + +def main(): + WORKSPACE.mkdir(exist_ok=True) + srv = ThreadingHTTPServer(("localhost", PORT), Handler) + print(f"web console on http://localhost:{PORT} (gateway {GATEWAY})", flush=True) + try: + srv.serve_forever() + except KeyboardInterrupt: + srv.shutdown() + finally: + # the tampered gateway is ours to clean up; the approved one belongs + # to run.py + TAMPERED.stop() + + +if __name__ == "__main__": + main() diff --git a/docs/repository-consolidation.md b/docs/repository-consolidation.md new file mode 100644 index 00000000..291af9a8 --- /dev/null +++ b/docs/repository-consolidation.md @@ -0,0 +1,48 @@ +# Examples and demos consolidation + +The canonical working repository is agentrust-io/integrations. Its existing +adapter and package paths remain supported. First-party scenarios live in +examples/, short demonstrations in demos/, and vendor submissions in integrations/. + +## Imported sources + +| Repository | Source main commit | Imported files | +|---|---|---| +| examples | 7c720e6cbc1f5cdee271f9c9b48238b5fd9d96e1 | 183 | +| demos | fd0a9ef166987a0e640598a8a935b8fe323e6b7b | 89 | + +Source licenses remain in examples/LICENSE (Apache-2.0) and demos/LICENSE (MIT). +Their copyright notices and evidence fixtures are retained. Source repository +history remains available in the original repositories; this import does not +rewrite or move that history. + +Source .github workflows and ownership settings are consolidated at the root. +Examples CI, example smoke tests, and demos CI are separate workflows. Existing +root CodeQL, actionlint, approval, link, and contributor checks cover the combined +repository. ClusterFuzzLite builds all three suites with their existing dependency +locks. Dependabot covers the imported Python roots. + +The demos web-console/vendor/examples submodule and .gitmodules are omitted. +The console runs the shared examples/financial-services scenario directly. + +## Review rules + +Examples retain maintainer review of every line and verified run claims. +Demos must assert their outcomes and pass the runnable demo checks. +Vendor integrations retain their declared community/verified tier and maintainer +responsibility. Moving code into this repository does not raise its verification tier. + +## Cutover + +1. Merge this migration after the examples, smoke, demos, and existing integration + checks pass. +2. Update the organization profile and website demo clone commands to this repository. +3. Update WCM's .github/workflows/python.yml consumer: checkout integrations, + use examples/weight-custody-manifest, and retain its wheel-against-examples check. +4. Add a moved notice to each source README pointing to the corresponding directory. +5. Recheck source issues, PRs, and main commits immediately before archival. + Both source issue/PR queues were empty at the initial inventory. +6. Archive the source repositories after their consumers use the combined repository. + Retain their published history, releases, and issue URLs; do not delete them. + +Historical issue links retain their original repository URLs. diff --git a/examples/.clusterfuzzlite/Dockerfile b/examples/.clusterfuzzlite/Dockerfile new file mode 100644 index 00000000..d82e2379 --- /dev/null +++ b/examples/.clusterfuzzlite/Dockerfile @@ -0,0 +1,5 @@ +FROM gcr.io/oss-fuzz-base/base-builder-python + +COPY . $SRC/integrations +COPY examples/.clusterfuzzlite/build.sh $SRC/build.sh +WORKDIR $SRC/integrations diff --git a/examples/.clusterfuzzlite/build.sh b/examples/.clusterfuzzlite/build.sh new file mode 100644 index 00000000..bc52dcdc --- /dev/null +++ b/examples/.clusterfuzzlite/build.sh @@ -0,0 +1,31 @@ +#!/bin/bash -eu +# Build the fuzz targets for ClusterFuzzLite. +# +# The targets import the example modules straight from their directories, the +# way a reader runs them. cryptography is the only third-party dependency, and +# it comes from the same hashed lock the receipts CI job uses. + +cd "$SRC/integrations/examples" +pip3 install --no-cache-dir --require-hashes -r requirements/receipts-tests.txt + +# compile_python_fuzzer bundles each target with PyInstaller, which follows +# static imports only. The cryptography stack reaches email.mime lazily, so +# without this the bundled target dies at runtime with +# "ModuleNotFoundError: No module named 'email.mime'" and libFuzzer reports it +# as a crash in the target. +PYI_ARGS=( + --collect-submodules=email + --paths="$SRC/integrations/examples/embodied-action-receipts" + --paths="$SRC/integrations/examples/agentic-commerce-accountability" + --paths="$SRC/integrations/examples/industrial-embodied-ai" +) + +for target in "$SRC"/integrations/examples/.clusterfuzzlite/fuzz_*.py; do + compile_python_fuzzer "$target" "${PYI_ARGS[@]}" +done + +# Seed each JSON target with the committed fixtures, so the fuzzer starts from +# documents that already get past parsing and, for receipts, past signature +# verification. +zip -j "$OUT/fuzz_receipts_seed_corpus.zip" embodied-action-receipts/fixtures/*.json +zip -j "$OUT/fuzz_purchase_seed_corpus.zip" agentic-commerce-accountability/fixtures/*.json diff --git a/examples/.clusterfuzzlite/fuzz_controller.py b/examples/.clusterfuzzlite/fuzz_controller.py new file mode 100644 index 00000000..75f601d1 --- /dev/null +++ b/examples/.clusterfuzzlite/fuzz_controller.py @@ -0,0 +1,93 @@ +#!/usr/bin/python3 +"""Fuzz the industrial example's mock safety controller. + +request_motion() receives the motion request an agent sends through the cMCP +gateway, arriving as JSON over HTTP. It must either accept a motion inside the +controller's envelope or raise SafetyRejected. The target mints a genuine state +token most of the time, so inputs get past the HMAC and reach the envelope +checks, then fuzzes the rest of the request. + +Two bugs sat here before the target: a NaN speed compared false against both +bounds and was accepted, and an integer too large for a float raised +OverflowError instead of SafetyRejected. +""" +import math +import sys +from pathlib import Path + +import atheris + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "industrial-embodied-ai")) + +with atheris.instrument_imports(): + from controller import ( + ALLOWED_TARGETS, + MAX_SPEED_MPS, + IndependentSafetyController, + SafetyRejected, + ) + +_TARGETS = sorted(ALLOWED_TARGETS) + + +class _Clock: + value = 1_781_179_200.0 + + def __call__(self) -> float: + return self.value + + +def _speed(fdp: atheris.FuzzedDataProvider) -> object: + kind = fdp.ConsumeIntInRange(0, 6) + if kind == 0: + return fdp.ConsumeRegularFloat() + if kind == 1: + return fdp.ConsumeFloat() # includes NaN and the infinities + if kind == 2: + return fdp.ConsumeInt(fdp.ConsumeIntInRange(1, 256)) + if kind == 3: + return fdp.ConsumeUnicodeNoSurrogates(24) + if kind == 4: + return fdp.ConsumeBool() + if kind == 5: + return None + return [fdp.ConsumeRegularFloat()] + + +def TestOneInput(data: bytes) -> None: + fdp = atheris.FuzzedDataProvider(data) + clock = _Clock() + controller = IndependentSafetyController(clock=clock, token_key=b"fuzz-only-controller-key") + token = controller.read_safety_state()["state_token"] + if fdp.ConsumeIntInRange(0, 7) == 0: + token = fdp.ConsumeUnicodeNoSurrogates(256) + clock.value += fdp.ConsumeIntInRange(0, 6000) / 1000 + request: dict[str, object] = {"safety_state_token": token} + if fdp.ConsumeBool(): + request["motion_id"] = fdp.ConsumeUnicodeNoSurrogates(16) + if fdp.ConsumeBool(): + request["target"] = ( + _TARGETS[fdp.ConsumeIntInRange(0, len(_TARGETS) - 1)] + if fdp.ConsumeBool() + else fdp.ConsumeUnicodeNoSurrogates(24) + ) + if fdp.ConsumeIntInRange(0, 9): + request["max_speed_mps"] = _speed(fdp) + + try: + result = controller.request_motion(request) + except SafetyRejected: + return + speed = float(request["max_speed_mps"]) + assert math.isfinite(speed) and 0 <= speed <= MAX_SPEED_MPS, request + assert request["target"] in ALLOWED_TARGETS, request + assert result["controller_decision"] == "accepted", result + + +def main() -> None: + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/examples/.clusterfuzzlite/fuzz_purchase.py b/examples/.clusterfuzzlite/fuzz_purchase.py new file mode 100644 index 00000000..5fcf971e --- /dev/null +++ b/examples/.clusterfuzzlite/fuzz_purchase.py @@ -0,0 +1,99 @@ +#!/usr/bin/python3 +"""Fuzz the agentic-commerce purchase verifier. + +verify() decides whether a purchase bundle (authority grant, request, policy +decision, runtime evidence, merchant receipt) hangs together. An empty error +list is an acceptance, so the property checked is the one that matters: when it +accepts, every constraint it claims to check really holds. Malformed shapes may +raise KeyError, TypeError, AttributeError or RecursionError, which the CLI turns +into a non-zero exit; that is a refusal, not an acceptance. + +A NaN spending ceiling, a negative amount and a string allow-list (which turns +membership into a substring test) each used to be accepted. +""" +import json +import sys +from pathlib import Path + +import atheris + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "agentic-commerce-accountability")) + +with atheris.instrument_imports(): + from verify_purchase import digest, verify + +_REFUSALS = (KeyError, TypeError, AttributeError, RecursionError) + + +def _check_acceptance(bundle: dict) -> None: + grant = bundle["authority_grant"] + request = bundle["purchase_request"] + decision = bundle["policy_decision"] + evidence = bundle["runtime_evidence"] + receipt = bundle["purchase_receipt"] + amount, ceiling = request["amount_minor"], grant["max_amount_minor"] + assert type(amount) is int and type(ceiling) is int, (amount, ceiling) + assert 0 < amount <= ceiling, (amount, ceiling) + assert isinstance(grant["allowed_operations"], list) + assert isinstance(grant["allowed_merchants"], list) + assert request["operation"] in grant["allowed_operations"] + assert request["merchant_id"] in grant["allowed_merchants"] + assert request["currency"] == grant["currency"] + assert evidence["runtime_identity"] == grant["delegate"] + assert decision["outcome"] == "allow" + assert decision["request_digest"] == digest(request) + assert decision["authority_digest"] == digest(grant) + assert evidence["policy_decision_digest"] == digest(decision) + assert receipt["request_digest"] == digest(request) + assert receipt["runtime_evidence_digest"] == digest(evidence) + + +def _rebind(bundle: dict) -> None: + """Re-derive every digest link from the fuzzed content. + + A fuzzer cannot find SHA-256 preimages, so without this nearly every + mutation dies on a digest mismatch and the constraint checks behind it are + never reached with a consistent chain. + """ + request, grant = bundle["purchase_request"], bundle["authority_grant"] + decision, evidence = bundle["policy_decision"], bundle["runtime_evidence"] + decision["request_digest"] = digest(request) + decision["authority_digest"] = digest(grant) + evidence["policy_decision_digest"] = digest(decision) + receipt = bundle["purchase_receipt"] + receipt["request_digest"] = digest(request) + receipt["runtime_evidence_digest"] = digest(evidence) + + +def _check(bundle: dict) -> None: + try: + errors = verify(bundle) + except _REFUSALS: + return + assert isinstance(errors, list), errors + if not errors: + _check_acceptance(bundle) + + +def TestOneInput(data: bytes) -> None: + try: + text = data.decode("utf-8") + _check(json.loads(text)) + except (UnicodeDecodeError, ValueError, RecursionError): + return + # The same document again with its digest chain made consistent. + rebound = json.loads(text) + try: + _rebind(rebound) + except _REFUSALS: + return + _check(rebound) + + +def main() -> None: + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/examples/.clusterfuzzlite/fuzz_receipts.py b/examples/.clusterfuzzlite/fuzz_receipts.py new file mode 100644 index 00000000..f9ff450b --- /dev/null +++ b/examples/.clusterfuzzlite/fuzz_receipts.py @@ -0,0 +1,55 @@ +#!/usr/bin/python3 +"""Fuzz the embodied-action receipt verifier. + +verify_text() reads a fixture an auditor was handed: a TRACE reference, an +action and a chain of controller-signed receipts. Every byte is attacker-chosen +until the Ed25519 check on each receipt passes. + +The property is the documented contract: it always returns a verdict, with +"valid" or "invalid" as the result, and never raises. Before this target +existed a wrong shape escaped as KeyError, TypeError, AttributeError or +binascii.Error, and a signature with stray characters in its base64 still +verified. The seed corpus is the committed fixtures, so mutations start from a +chain that already verifies. +""" +import sys +from pathlib import Path + +import atheris + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "embodied-action-receipts")) + +with atheris.instrument_imports(): + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + from verify_receipts import TrustedSigner, b64url_decode, verify_text + +# The public test key from embodied-action-receipts/trusted-keys.json, inlined +# because PyInstaller bundles code, not the data files beside it. +_TRUSTED = { + "robot-cell-7-controller": TrustedSigner( + issuer="spiffe://factory.example/controller/robot-cell-7", + public_key=Ed25519PublicKey.from_public_bytes( + b64url_decode("A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg") + ), + ) +} + + +def TestOneInput(data: bytes) -> None: + try: + text = data.decode("utf-8") + except UnicodeDecodeError: + return + result = verify_text(text, _TRUSTED) + assert isinstance(result, dict), result + assert result.get("result") in {"valid", "invalid"}, result + assert isinstance(result.get("receipt_state"), str), result + + +def main() -> None: + atheris.Setup(sys.argv, TestOneInput) + atheris.Fuzz() + + +if __name__ == "__main__": + main() diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 00000000..6cbac9e3 --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1,6 @@ +# Demo runtime artifacts +*.db +*.db-shm +*.db-wal +__pycache__/ +*.pyc diff --git a/examples/CODE_OF_CONDUCT.md b/examples/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..d1654f5d --- /dev/null +++ b/examples/CODE_OF_CONDUCT.md @@ -0,0 +1,17 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We pledge to make participation in our community a harassment-free experience for everyone. + +## Our Standards + +Positive behavior: welcoming and inclusive language, respecting differing viewpoints, gracefully accepting constructive criticism, focusing on what is best for the community. + +Unacceptable behavior: sexualized language or imagery, trolling, insulting comments, personal or political attacks, public or private harassment, publishing others' private information without permission. + +## Enforcement + +Report instances of abusive behavior via [GitHub Security Advisories](https://github.com/agentrust-io/integrations/security/advisories/new). + +Adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1. diff --git a/examples/CONTRIBUTING.md b/examples/CONTRIBUTING.md new file mode 100644 index 00000000..4423a512 --- /dev/null +++ b/examples/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# Contributing + +Thank you for your interest in contributing to AgenTrust examples. + +## Scope: what belongs here + +This directory carries first-party examples and flagship partner examples developed with the maintainers. Every line is reviewed and every claim in a README is verified against a real run before merge. + +**Vendor and product integrations belong in [agentrust-io/integrations](https://github.com/agentrust-io/integrations)** - self-serve, with published rules and a community/verified tier model. Add vendor integrations under `../integrations/` instead. + +## How to Contribute + +1. Fork the repository and create a feature branch (`git checkout -b feat/my-example`) +2. Commit using [Conventional Commits](https://www.conventionalcommits.org) +3. Open a pull request against `main` with a clear description of the example + +## Adding an Example + +Each example should live in its own subdirectory with a self-contained `README.md` and runnable code. + +## Reporting Security Issues + +Use [GitHub Security Advisories](https://github.com/agentrust-io/integrations/security/advisories/new) rather than public issues. + +## Code of Conduct + +See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). diff --git a/examples/LICENSE b/examples/LICENSE new file mode 100644 index 00000000..57bc88a1 --- /dev/null +++ b/examples/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/examples/MAINTAINERS.md b/examples/MAINTAINERS.md new file mode 100644 index 00000000..019f4423 --- /dev/null +++ b/examples/MAINTAINERS.md @@ -0,0 +1,9 @@ +# Maintainers + +## Repository Maintainers + +| Name | GitHub | Appointment | +|---|---|---| +| Carlos Hernandez | [@carloshvp](https://github.com/carloshvp) | [Project Lead announcement](https://github.com/orgs/agentrust-io/discussions/20) | + +These appointments cover this repository. CODEOWNERS determines which paths each reviewer can approve for protected merges. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..720b454d --- /dev/null +++ b/examples/README.md @@ -0,0 +1,68 @@ +[![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) +[![cMCP](https://img.shields.io/badge/Uses-cMCP_Runtime-7c3aed)](https://github.com/agentrust-io/cmcp) +[![Agent Manifest](https://img.shields.io/badge/Uses-Agent_Manifest-0ea5e9)](https://github.com/agentrust-io/agent-manifest) +[![cA2A](https://img.shields.io/badge/Uses-cA2A-7c3aed)](https://github.com/agentrust-io/ca2a) +[![Discord](https://dcbadge.limes.pink/api/server/grgzFEHgkj?style=flat)](https://discord.gg/grgzFEHgkj) + +# agentrust-io Examples + +Community updates and contributor highlights: [AgenTrust on LinkedIn](https://www.linkedin.com/company/agentrust-io/). + +End-to-end integration examples showing cMCP, Agent Manifest, and TRACE working together across deployment scenarios. Each example is self-contained and runnable on a fresh cloud VM. Running them shows how the projects compose: cMCP enforces policy at the tool call boundary, Agent Manifest carries the identity and capability declaration, cA2A attenuates authority at the agent-to-agent boundary, and TRACE emits a signed Trust Record for every tool invocation so you can see what the full audit trail looks like in practice. + +Project support is recognized in [SPONSORS.md](SPONSORS.md). Sponsorship is +separate from example authorship, fixture identities, partner provenance, +adoption, and project governance. + +## Examples + +| Example | What it shows | Platform | Compliance | +|---|---|---|---| +| `agentic-commerce-accountability/` | Delegated spend authority linked to checkout, policy, runtime evidence and receipt, with offline tamper detection | Software-only (offline) | Accountability, least authority | +| `ca2a-delegation/` | Agent-to-agent delegation with cA2A: attenuated credit-workflow chain, offline verification, and a rejected scope escalation | Software-only (offline) | Separation of duties, least authority | +| `embodied-action-receipts/` | Fixture-style offline verification for embodied action receipts: accepted chain, missing receipt, signature mismatch and valid controller rejection | Software-only fixtures | TRACE action-receipt evidence boundary | +| `financial-services/` | Corporate credit risk agent: six-step assessment with CDD, exposure and IFRS 9 guardrails on the write | SEV-SNP / TDX | EU AI Act Art. 9/12, CRR Art. 395, EBA/GL/2020/06, EU AML, DORA Art. 9 | +| `healthcare/` | Clinical agent on a coherent ICD-10 patient: drug-interaction check feeds EU AI Act Art. 14 HITL and contraindication denies | SEV-SNP / TDX | EU AI Act Art. 14, HIPAA | +| `industrial-embodied-ai/` | Material-movement agent with cMCP authorization, an independent safety-controller boundary and offline-verifiable closed-session evidence | TEE / software-only development mode | OT security and industrial robot safety references | +| `multi-tenant-saas/` | HR SaaS with an EU tenant (enforcing GDPR residency/Art. 9) and a US tenant (advisory) on one catalog | TDX | GDPR Art. 6/9/44, customer DPA | +| `startup-tpm/` | 15-minute quickstart on any cloud VM with Trusted Launch | TPM 2.0 | Development / staging | + +Most examples are fully runnable with no external dependencies: they ship a mock upstream MCP server, an agent script, an attested tool catalog, and a Cedar policy bundle, and end by printing the signed TRACE Trust Record for the session. The `trace-output/` files are captured from real runs. The `ca2a-delegation/` and `embodied-action-receipts/` examples are offline verifiers and ship their captured chains instead. + +## Quickstart + +```bash +git clone https://github.com/agentrust-io/integrations.git +cd integrations/examples/startup-tpm +pip install cmcp-runtime httpx + +# Terminal 1: mock upstream MCP server +python server/mock_mcp_server.py + +# Terminal 2: the runtime (CMCP_DEV_MODE=1 for machines without a TPM/TEE) +CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml + +# Terminal 3: one tool call + signed TRACE Trust Record +python agent/echo_agent.py +``` + +See `startup-tpm/README.md` for the full walkthrough. + +## Prerequisites + +- Python 3.11+ +- An MCP server to protect (existing servers work unchanged) +- For Level 1 attestation: a VM with TPM 2.0, AMD SEV-SNP, or Intel TDX +- For GPU-CC attestation (v0.2): NVIDIA H100/H200 with CC mode enabled + +## Status + +Launching at Confidential Computing Summit, San Francisco, June 23 2026. + +## Community + +Questions, feedback, integration help: [Discord](https://discord.gg/grgzFEHgkj). + +## License + +Apache 2.0 diff --git a/examples/SECURITY.md b/examples/SECURITY.md new file mode 100644 index 00000000..1ba1b212 --- /dev/null +++ b/examples/SECURITY.md @@ -0,0 +1,38 @@ +# Security Policy + +## Reporting a Vulnerability + +**Do not open a public GitHub issue for security vulnerabilities.** + +Report privately via [GitHub Security Advisories](https://github.com/agentrust-io/integrations/security/advisories/new). You will receive a confirmation within 2 business days and a triage decision within 5 business days. + +## Response SLAs + +| Severity | Definition | Fix Target | +|----------|------------|------------| +| Critical | An example that verifies nothing while appearing to, or that would leak a real credential if followed as written | 30 days from confirmed report | +| High / Medium / Low | All other confirmed vulnerabilities | 90 days from confirmed report | + +The timeline starts when the issue is confirmed as a valid vulnerability, not on initial receipt. + +## Scope + +These are reference examples, which is exactly why a flaw in one travels. Code here is meant to be copied. + +- **An example that teaches an unsafe pattern.** Verification switched off to make a sample run, a signature checked and the result discarded, a trust anchor hardcoded without saying that a real deployment must not do that. Report it. +- **An example whose output misstates what happened.** If a snippet prints that a record verified when the call failed or was skipped, that is a security defect here, because the example is a claim about behaviour that a reader will rely on. +- **Response scanners and detectors** under this repository, where a missed detection is the failure mode that matters. A known false positive, such as a ten-digit number read as a phone number, is a bug rather than a vulnerability. + +## Not in scope + +- **Vulnerabilities in the upstream components being demonstrated.** Those belong to agentrust-io/cmcp, agentrust-io/agent-manifest, agentrust-io/trace-spec and their siblings, each with its own policy. If an example misuses one of them, that part is ours. +- **The example services as production systems.** They are not hardened for network exposure. Report an unsafe default a reader would carry into production, not the absence of hardening in a sample. +- **Synthetic fixtures, sample keys and test vectors**, which exist to be published. + +## What to include + +The example, the command you ran, its output, and what it should have been. A minimal reproducer is the fastest route to a fix. + +## Disclosure + +We prefer coordinated disclosure. Tell us if you intend to publish and we will agree a date. We will credit you in the advisory unless you ask us not to. diff --git a/examples/SPONSORS.md b/examples/SPONSORS.md new file mode 100644 index 00000000..245f0176 --- /dev/null +++ b/examples/SPONSORS.md @@ -0,0 +1,20 @@ +# Sponsors + +AgenTrust Examples is an open-source project. Sponsors provide funding, +engineering time, infrastructure, or other in-kind support. Sponsorship does +not confer project ownership, governance authority, or control over technical +decisions or example results. + +## Current sponsors + +### OPAQUE Systems + +Founding engineering and infrastructure sponsor, and contributor to the +AgenTrust example ecosystem. + +## Recognition policy + +Sponsor recognition is separate from example authorship, partner provenance, +and adoption. Vendor names and identities inside example manifests, policies, +fixtures, and deployment profiles describe the scenario being exercised; they +do not identify the owner of this repository or its projects. diff --git a/examples/TRACE-VERSION-NOTE.md b/examples/TRACE-VERSION-NOTE.md new file mode 100644 index 00000000..585cb536 --- /dev/null +++ b/examples/TRACE-VERSION-NOTE.md @@ -0,0 +1,44 @@ +# The recorded trust records here are TRACE v0.1 + +Every `*/trace-output/*.json` in this repository, plus +`industrial-embodied-ai/agent-manifest.json`, is the recorded output of a demo run +that happened while the stack emitted **TRACE v0.1**, carrying the profile URI +`tag:agentrust.io,2026:trace-v0.1`. + +The stack now emits **v0.2** (`tag:agentrust-io.com,2026:trace-v0.2`). The v0.1 +profile URI named a domain this project never controlled, which RFC 4151 does not +permit for a tag URI. See [trace-spec#107](https://github.com/agentrust-io/trace-spec/pull/107). + +## Why these were not rewritten + +Eleven of them carry a real Ed25519 signature over the record, and the profile URI +sits inside the signed payload. Editing the string while keeping the old signature +would produce a record that **fails verification**: an artifact claiming v0.2 that +no v0.2 verifier would accept. These examples exist to demonstrate verifiable +evidence, so shipping a record that does not verify is worse than shipping one that +is honestly a version behind. + +The signing keys are deliberately not committed, so they cannot be re-signed here. + +Three further records carry no signature +(`healthcare/{sg-moh,uk-nhs,us-fda-samd}`). Those were held back too, so the +recorded corpus stays internally consistent rather than half-migrated. + +## What to do instead + +Regenerate them by re-running the examples against the current stack +(`agentrust-trace>=0.5`, cMCP on the v0.2 profile). The regenerated records will +carry the v0.2 profile with a signature that covers it. + +Until then, read them as what they are: evidence of runs that happened under v0.1, +still verifiable with `agentrust-trace-tests` 0.3.x. + +Documentation in this repository **has** moved to v0.2 and to `agentrust-io.com`, +since nothing signs it. + +`industrial-embodied-ai/policy/manifest.json` and `industrial-embodied-ai/catalog.json` +were also held back, for a subtler reason: nothing signs them directly, but their +SHA-256 digests are recorded inside the signed trust record as +`cmcp_policy_bundle_hash` and the catalog hash. Editing a byte of either breaks the +evidence chain even though neither file carries a signature of its own, which +`validate_artifacts.py` catches. They regenerate with the records. diff --git a/examples/agentic-commerce-accountability/README.md b/examples/agentic-commerce-accountability/README.md new file mode 100644 index 00000000..8140426b --- /dev/null +++ b/examples/agentic-commerce-accountability/README.md @@ -0,0 +1,33 @@ +# Agentic Commerce Accountability + +For released UCP schema validation, authenticated HTTP artifacts, one-purchase +authority, and retry/concurrency tests, see the separate +[released-UCP verification harness](released_ucp/README.md). The illustrative +example below stays deliberately small. + +This runnable example asks whether an auditor can connect a completed purchase +to the authority the user granted, the exact request evaluated by policy, and +the runtime evidence for that decision. + +The bundle links a constrained authority grant, a UCP-shaped checkout request, +an AGT-style policy decision, a TRACE evidence reference, and a merchant receipt +with canonical SHA-256 digests. The valid fixture stays below a delegated USD +200 limit. The tampered fixture changes the purchase to USD 500 after the policy +decision, so verification detects the overspend and broken digest link. + +```bash +python generate_fixtures.py +python -m unittest discover -s tests -v +python verify_purchase.py fixtures/valid-purchase.json +python verify_purchase.py fixtures/overspend-tamper.json +``` + +## Security boundary + +This is a deterministic composition example, not a claim of UCP, AGT, cA2A, or +TRACE conformance. It verifies constraints, that the runtime named in the +evidence is the grant's delegate, and cross-artifact bindings. It does not +verify signatures, grant expiry, merchant settlement, hardware quotes, +revocation, or a live transparency-log receipt. Production use should replace each illustrative +artifact with the corresponding protocol's signed, independently verifiable +record. diff --git a/examples/agentic-commerce-accountability/fixtures/overspend-tamper.json b/examples/agentic-commerce-accountability/fixtures/overspend-tamper.json new file mode 100644 index 00000000..c3f15ff5 --- /dev/null +++ b/examples/agentic-commerce-accountability/fixtures/overspend-tamper.json @@ -0,0 +1,44 @@ +{ + "authority_grant": { + "grant_id": "grant-2026-08-24-001", + "principal": "user:alice", + "delegate": "spiffe://buyer.example/agent/travel", + "allowed_operations": [ + "ucp.checkout.complete" + ], + "allowed_merchants": [ + "merchant:hotel-example" + ], + "currency": "USD", + "max_amount_minor": 20000, + "expires_at": "2026-08-25T00:00:00Z" + }, + "purchase_request": { + "request_id": "checkout-001", + "operation": "ucp.checkout.complete", + "merchant_id": "merchant:hotel-example", + "currency": "USD", + "amount_minor": 50000, + "cart_digest": "sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + }, + "policy_decision": { + "decision_id": "agt-decision-001", + "outcome": "allow", + "policy_id": "commerce-spend-v1", + "authority_digest": "sha256:a81ddc9c8c8d6c442e999ed2666e0d722d6ded1be3943c1a24e884735c7e9e1c", + "request_digest": "sha256:7069a7a4ae68fd431f6dd697327478d9cb07cd4d04f77cf08e9e1ea6c4653324" + }, + "runtime_evidence": { + "evidence_id": "trace-record-001", + "profile": "illustrative-trace-reference", + "runtime_identity": "spiffe://buyer.example/agent/travel", + "policy_decision_digest": "sha256:e0c2b5df43b9f44cc222a936d57774dfdf60857db65e2714b46cc15554cfa323", + "attestation_reference": "urn:example:attestation:tdx:001" + }, + "purchase_receipt": { + "receipt_id": "merchant-receipt-001", + "status": "completed", + "request_digest": "sha256:7069a7a4ae68fd431f6dd697327478d9cb07cd4d04f77cf08e9e1ea6c4653324", + "runtime_evidence_digest": "sha256:712e86343a9f149a8d3a7d25a350ee9051fdbb909be694972e334021a682b8e5" + } +} diff --git a/examples/agentic-commerce-accountability/fixtures/valid-purchase.json b/examples/agentic-commerce-accountability/fixtures/valid-purchase.json new file mode 100644 index 00000000..4fcbf8eb --- /dev/null +++ b/examples/agentic-commerce-accountability/fixtures/valid-purchase.json @@ -0,0 +1,44 @@ +{ + "authority_grant": { + "grant_id": "grant-2026-08-24-001", + "principal": "user:alice", + "delegate": "spiffe://buyer.example/agent/travel", + "allowed_operations": [ + "ucp.checkout.complete" + ], + "allowed_merchants": [ + "merchant:hotel-example" + ], + "currency": "USD", + "max_amount_minor": 20000, + "expires_at": "2026-08-25T00:00:00Z" + }, + "purchase_request": { + "request_id": "checkout-001", + "operation": "ucp.checkout.complete", + "merchant_id": "merchant:hotel-example", + "currency": "USD", + "amount_minor": 12500, + "cart_digest": "sha256:a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" + }, + "policy_decision": { + "decision_id": "agt-decision-001", + "outcome": "allow", + "policy_id": "commerce-spend-v1", + "authority_digest": "sha256:a81ddc9c8c8d6c442e999ed2666e0d722d6ded1be3943c1a24e884735c7e9e1c", + "request_digest": "sha256:7069a7a4ae68fd431f6dd697327478d9cb07cd4d04f77cf08e9e1ea6c4653324" + }, + "runtime_evidence": { + "evidence_id": "trace-record-001", + "profile": "illustrative-trace-reference", + "runtime_identity": "spiffe://buyer.example/agent/travel", + "policy_decision_digest": "sha256:e0c2b5df43b9f44cc222a936d57774dfdf60857db65e2714b46cc15554cfa323", + "attestation_reference": "urn:example:attestation:tdx:001" + }, + "purchase_receipt": { + "receipt_id": "merchant-receipt-001", + "status": "completed", + "request_digest": "sha256:7069a7a4ae68fd431f6dd697327478d9cb07cd4d04f77cf08e9e1ea6c4653324", + "runtime_evidence_digest": "sha256:712e86343a9f149a8d3a7d25a350ee9051fdbb909be694972e334021a682b8e5" + } +} diff --git a/examples/agentic-commerce-accountability/generate_fixtures.py b/examples/agentic-commerce-accountability/generate_fixtures.py new file mode 100644 index 00000000..75f371a6 --- /dev/null +++ b/examples/agentic-commerce-accountability/generate_fixtures.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +"""Generate deterministic fixtures for the commerce accountability example.""" +from __future__ import annotations + +import json +from copy import deepcopy +from pathlib import Path + +from verify_purchase import digest + +HERE = Path(__file__).resolve().parent +FIXTURES = HERE / "fixtures" + + +def build(amount_minor: int = 12_500) -> dict: + grant = {"grant_id": "grant-2026-08-24-001", "principal": "user:alice", "delegate": "spiffe://buyer.example/agent/travel", "allowed_operations": ["ucp.checkout.complete"], "allowed_merchants": ["merchant:hotel-example"], "currency": "USD", "max_amount_minor": 20_000, "expires_at": "2026-08-25T00:00:00Z"} + request = {"request_id": "checkout-001", "operation": "ucp.checkout.complete", "merchant_id": "merchant:hotel-example", "currency": "USD", "amount_minor": amount_minor, "cart_digest": "sha256:" + "a1" * 32} + decision = {"decision_id": "agt-decision-001", "outcome": "allow", "policy_id": "commerce-spend-v1", "authority_digest": digest(grant), "request_digest": digest(request)} + evidence = {"evidence_id": "trace-record-001", "profile": "illustrative-trace-reference", "runtime_identity": "spiffe://buyer.example/agent/travel", "policy_decision_digest": digest(decision), "attestation_reference": "urn:example:attestation:tdx:001"} + receipt = {"receipt_id": "merchant-receipt-001", "status": "completed", "request_digest": digest(request), "runtime_evidence_digest": digest(evidence)} + return {"authority_grant": grant, "purchase_request": request, "policy_decision": decision, "runtime_evidence": evidence, "purchase_receipt": receipt} + + +def write(name: str, value: dict) -> None: + FIXTURES.mkdir(exist_ok=True) + (FIXTURES / name).write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8") + + +if __name__ == "__main__": + valid = build() + write("valid-purchase.json", valid) + overspend = deepcopy(valid) + overspend["purchase_request"]["amount_minor"] = 50_000 + write("overspend-tamper.json", overspend) diff --git a/examples/agentic-commerce-accountability/released_ucp/.dockerignore b/examples/agentic-commerce-accountability/released_ucp/.dockerignore new file mode 100644 index 00000000..3cd46b0b --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/.dockerignore @@ -0,0 +1,59 @@ +.git +.git/** +.venv +**/.venv +**/__pycache__ +**/*.py[cod] +.pytest_cache +**/.pytest_cache +.mypy_cache +**/.mypy_cache +.ruff_cache +**/.ruff_cache +.cache +**/.cache +.uv-cache +.coverage +.coverage.* +coverage.xml +coverage.json +htmlcov +*.egg-info +build +dist +.env +.env.* +.secrets.baseline +**/.secrets.baseline +.DS_Store +.idea +.vscode +*.log +**/*.log +*.db +**/*.db +*.db-* +**/*.db-* +*.sqlite +**/*.sqlite +*.sqlite-* +**/*.sqlite-* +*.sqlite3 +**/*.sqlite3 +*.sqlite3-* +**/*.sqlite3-* +*.pem +**/*.pem +*.key +**/*.key +run +**/run +private +**/private +evidence +**/evidence +artifacts +output +outputs +ucp-source +ucp-schema-source diff --git a/examples/agentic-commerce-accountability/released_ucp/.gitattributes b/examples/agentic-commerce-accountability/released_ucp/.gitattributes new file mode 100644 index 00000000..6bc58748 --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/.gitattributes @@ -0,0 +1,5 @@ +# Preserve the bytes covered by schemas/provenance.json on every checkout. +schemas/*.json text eol=lf +schemas/LICENSE-UCP text eol=lf +# Executable POSIX scripts must not acquire CRLF line endings. +scripts/*.sh text eol=lf diff --git a/examples/agentic-commerce-accountability/released_ucp/.gitignore b/examples/agentic-commerce-accountability/released_ucp/.gitignore new file mode 100644 index 00000000..2890abf6 --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/.gitignore @@ -0,0 +1,15 @@ +.venv/ +__pycache__/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage* +coverage.xml +*.sqlite* +*.db* +*.log +run/ +dist/ +build/ +*.egg-info/ +.secrets.baseline diff --git a/examples/agentic-commerce-accountability/released_ucp/Dockerfile b/examples/agentic-commerce-accountability/released_ucp/Dockerfile new file mode 100644 index 00000000..f851506a --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/Dockerfile @@ -0,0 +1,22 @@ +# A pinned multi-architecture base, not a claim of bit-reproducible image output. +FROM python:3.12.11-slim-bookworm@sha256:519591d6871b7bc437060736b9f7456b8731f1499a57e22e6c285135ae657bf7 + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_PYTHON_DOWNLOADS=never \ + UV_NO_PROGRESS=1 \ + UV_NO_CACHE=1 + +COPY uv-bootstrap.txt /opt/uv-bootstrap.txt +RUN python -m pip install --disable-pip-version-check --no-cache-dir \ + --only-binary=:all: --require-hashes -r /opt/uv-bootstrap.txt + +WORKDIR /app +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-managed-python + +RUN useradd --uid 10001 --user-group --create-home --shell /usr/sbin/nologin runner +COPY --chown=10001:10001 . . +USER 10001:10001 + +CMD ["./scripts/smoke.sh"] diff --git a/examples/agentic-commerce-accountability/released_ucp/README.md b/examples/agentic-commerce-accountability/released_ucp/README.md new file mode 100644 index 00000000..f9cdc5d0 --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/README.md @@ -0,0 +1,208 @@ +# Released UCP commerce accountability + +A runnable, offline example connecting an authenticated merchant checkout, +explicit spending authority, a local policy decision, and independently +verifiable evidence. This advances [examples #89](https://github.com/agentrust-io/examples/issues/89) +using released UCP **2026-08-25** artifacts. The existing parent-directory +illustrative example remains unchanged. + +**This is a verification harness, not an interactive broker or payment system.** +One fictional reservation, USD, one configured merchant/platform pair, and empty +payment instruments keep the supported profile deliberately small. Completion +changes local SQLite rows; no money moves and no real merchant is contacted. + +| Start here | Purpose | +| --- | --- | +| [Run the smoke](#run-it) | One no-network container command after the build | +| [Business boundary](ucp_commerce/flow.py) | Authorization, transaction, retry cache, independent audit | +| [Protocol signatures](ucp_commerce/wire.py) | Released RFC 9421 library plus raw-body and coverage checks | +| [Schema provenance](schemas/README.md) | Exact released inputs, hashes, regeneration, limitations | +| [Acceptance tests](#acceptance-evidence) | Concrete assertions, including refusal paths | + +## Run it + +From this directory, with Docker available: + +```sh +docker build --no-cache --tag released-ucp-accountability:local . +docker run --rm --network none --read-only --cap-drop ALL \ + --security-opt no-new-privileges --pids-limit 128 \ + --memory 1g --cpus 2 --tmpfs /tmp:rw,nosuid,nodev,size=256m \ + released-ucp-accountability:local +``` + +Expected result: an allowed simulated purchase whose evidence passes independent +audit, passing tests, a measured branch-coverage report, Ruff checks/formatting, +mypy, Bandit, and the final line `OFFLINE_SMOKE_PASSED`. A failing stage stops the +smoke. CI runs this same script as non-root UID/GID `10001:10001`, without network +access, writable image layers, Linux capabilities, secrets, or publishing steps. + +For host development with Python 3.12 and uv 0.12.5: + +```sh +uv sync --frozen +uv run --frozen --offline --no-sync python -m ucp_commerce +./scripts/smoke.sh +``` + +Optional separate-process evidence verification, within the local freshness +window: + +```sh +uv run --frozen --offline --no-sync python -m ucp_commerce --output ./run/demo +uv run --frozen --offline --no-sync python -m ucp_commerce audit \ + ./run/demo/bundle.json --trust ./run/demo/public-trust.json +``` + +The output contains signed evidence and public keys, not private signing keys. +For this demonstration the operator generated those keys. For other evidence, +select trusted keys independently: accepting a stranger's bundle and its +self-supplied trust file would not authenticate the stranger. Generated output +and databases are local run artifacts, not fixtures to commit. + +The initial build/install needs dependency-download access. Verification does +not. The image has a digest-pinned Python base, hash-verified uv bootstrap wheels, +and a locked Python dependency graph. This is **not** a claim of bit-reproducible +image output. No package is published by this example. + +## What crosses the trust boundary + +```text +Configured authority key ── signs bounded local grant ──┐ +Configured merchant key ── signs UCP checkout ─────────┤ +Configured platform key ── signs exact completion ─────┤ + ▼ + Merchant.complete: authenticate + authorize + │ + one SQLite transaction: spend + local effect + + cached signed result + │ + merchant UCP response + TRACE record + decision JWS + ▼ + read-only audit with independent trusted keys +``` + +The demo generates four disposable signing keys in one local process. The roles +are cryptographically separate, not deployed as separate security domains. The +auditor receives trusted public keys out of band; neither `kid` nor a key inside +submitted evidence grants trust. A configured merchant key represents only that +local configured signer, not verified ownership of a real business or domain. + +### Readable allow trace + +1. The merchant supplies a signed, schema-valid `ready_for_complete` checkout + for a fictional USD 125.00 reservation. +2. A separately signed local grant permits `checkout.complete`, USD, the exact + configured merchant and platform keys, a USD 200.00 cap, and a bounded expiry. +3. The platform signs `POST /checkout-sessions/{id}/complete`, its raw JSON body, + a fresh 128-bit-random idempotency key, and the configured `UCP-Agent` profile + declaration. The supported request contains empty instruments. +4. Under a SQLite write lock, the merchant authenticates the evidence, checks + the supported profile and authority, and compares the supplied signed + checkout with current local checkout state before invoking + its private completion handler. +5. One transaction records the spend, simulated effect, and cached result. The + result includes a signed completed UCP response, a released TRACE record, and + a canonical signed decision receipt binding their digests. +6. The independent audit re-verifies signatures, schemas, policy, purchase terms, + and receipt bindings without invoking a handler or modifying redemption state. + +## Three different kinds of evidence + +| Artifact | Exact meaning and implementation | +| --- | --- | +| Local authority grant | Strict, closed example schema; RFC 8785 canonical JSON and an Ed25519 compact JWS through `joserfc`. These are local spending-policy semantics, not a UCP/AP2 mandate. | +| UCP HTTP artifacts | ES256 signatures through `http-message-signatures`; RFC 9530 SHA-256 covers the **original body bytes**, not canonicalized JSON. Request target and required headers are covered; a standard response signature covers status and body, **not its enclosing request URL/method**. | +| TRACE plus decision receipt | Released `agentrust-trace` signing, schema validation and verification authenticate a software-only policy statement. A separate Ed25519 decision JWS commits the grant, checkout body, request/response snapshots, TRACE record, policy, key bindings, and per-call invocation number. | + +The wire layer checks `Content-Digest` and required signature coverage itself; +the signature library alone does not establish those application conditions. +UCP derives the algorithm from the key, so the HTTP `Signature-Input` omits +`alg`. The five-minute signature/audit window is **local policy**, not an +additional UCP protocol requirement. HTTP snapshots are constructed and verified +in-process; this does not test a deployed HTTPS transport or discovery client. +The merchant checks the authenticated `UCP-Agent` declaration against the local +configured profile URL. It never fetches that URL or trusts keys obtained from +it; profile hosting and discovery remain outside this example. + +The authority is deliberately **cap-based**: the grant permits one supported +USD purchase under its signed limit. The platform signature binds the checkout +ID/route and completion body, **not a particular quoted price or revision**. +The merchant checks the supplied authenticated quote against current state and +its local policy. This is not AP2-style buyer consent to an exact signed price. + +The TRACE record declares `origin.kind=self`, `appraisal.status=none`, no model, +and SLSA level 0. Its `evidence.py` source-file hash is an operator-generated +description of that helper, not a whole-program measurement, hardware evidence, +or proof that the code executed. TRACE +alone does not bind the purchase; the outer decision and the complete audit do. + +## State and retry guarantees + +- Identical client/target/idempotency-key retries with the same raw-body digest + return the original cached signed bundle, without a second handler invocation. +- Conflicting raw bytes under the same scoped key are refused. Semantically + equivalent reserialized JSON is not the same RFC 9530 body digest. +- A fresh idempotency key does not let a spent grant authorize a second purchase. +- Authentication and authorization denials precede handler invocation. An error + after invocation is a transaction abort, not evidence that no handler ran. +- Spend, simulated effect, and cached result commit or roll back together because + **all effects are local SQLite changes**. This cannot be generalized to + exactly-once external payment execution or settlement. + +The SQLite database retains retry entries for its lifetime, with no automatic +pruning. This short-lived harness revalidates authority before looking up a +cached result, and audit imposes a five-minute freshness window. It does not +implement a production 24-hour retry SLA, durable archival validation, distributed +idempotency, user-consent enrollment, or a revocation-distribution service. + +## Acceptance evidence + +The test suite uses the actual released schema/model, signature, and TRACE APIs. +Fault injection is confined to explicitly named unit tests; it is not presented +as protocol interoperability evidence. The complete smoke measures branch +coverage rather than assigning an invented coverage target. + +| Invariant | Enforcing layer and representative test | +| --- | --- | +| Released schemas before SDK validation; original payload unchanged | `schema.py`; `test_checkout_passes_released_schema_and_sdk_without_mutation`, `test_checkout_rejects_invalid_amount_before_sdk_coercion` | +| Frozen offline schema inputs | `schemas/provenance.json`; `test_vendored_schemas_are_valid_offline_and_match_provenance` | +| Authenticated exact HTTP target and body | `wire.py`; `test_request_target_substitutions_are_denied`, `test_digest_checks_raw_bytes_not_equivalent_json` | +| Authenticated configured platform-profile declaration | `flow.py`; `test_missing_or_wrong_authenticated_platform_profile_is_denied` | +| Required signature coverage, not merely a valid signature | `wire.py`; `test_valid_signature_with_insufficient_request_coverage_is_denied`, `test_valid_signature_with_insufficient_response_coverage_is_denied` | +| Authority comes from configured trust | `evidence.py`; `test_grant_requires_configured_key`, `test_grant_payload_substitution_fails_signature` | +| Strict authenticated grant fields | `evidence.py`; `test_correctly_signed_invalid_claim_is_refused`, `test_grant_rejects_ambiguous_or_open_schema_payloads` | +| TRACE is authenticated but not purchase authority | `evidence.py`; `test_trace_signature_is_not_purchase_authorization`, `test_nonpolicy_http_data_needs_the_outer_decision_binding` | +| No hardware-assurance inflation | `evidence.py`; `test_correctly_signed_trace_must_keep_local_assurance_ceiling` | +| Correctly signed overspend still refused before the handler | `flow.py`; `test_correctly_signed_overspend_is_policy_denial` | +| Merchant/key/currency authority is independently checked | `flow.py`; `test_signed_authority_constraints_are_enforced`, `test_untrusted_keys_cannot_self_authorize` | +| An authenticated stale checkout is not current approval | `flow.py`; `test_stale_signed_quote_is_rejected_against_current_checkout` | +| Matching retries cache; changed bytes conflict; fresh keys cannot re-spend a grant | `flow.py`; `test_identical_retry_returns_original_signed_bundle`, `test_same_idempotency_key_with_changed_raw_payload_conflicts`, `test_new_idempotency_key_does_not_redeem_spent_grant` | +| Concurrency produces one spend/effect, with correct per-call receipt IDs | `flow.py`; `test_concurrent_identical_requests_have_one_handler_call`, `test_concurrent_fresh_keys_still_spend_grant_once`, `test_parallel_purchases_receipts_match_each_own_invocation` | +| Lock waits cannot preserve expired authority | `flow.py`; `test_expiry_rechecked_after_sqlite_lock_wait`, `test_pre_handler_final_freshness_recheck` | +| Post-handler failure is an abort, not a denial/no-invocation claim | `flow.py`; `test_post_handler_signing_failure_rolls_back_without_denial_claim` | +| Receipt links and independently configured trust are checked | `flow.py`; `test_auditor_rejects_cross_bound_signed_artifact_substitution`, `test_auditor_requires_each_independently_configured_trust_key` | +| Audit authenticates observations, not database truth | `flow.py`; `test_audit_authenticates_controller_assertion_not_database_state` | + +Business-boundary refusal, retry, and concurrency cases are in +[`tests/test_flow.py`](tests/test_flow.py); the `INV-*` comments in +[`flow.py`](ucp_commerce/flow.py) identify the enforcing transaction boundaries. + +## Released sources and nonclaims + +- [UCP v2026-08-25 Checkout schema](https://github.com/Universal-Commerce-Protocol/ucp/blob/v2026-08-25/source/schemas/shopping/checkout.json), + [REST binding](https://github.com/Universal-Commerce-Protocol/ucp/blob/v2026-08-25/docs/specification/shopping/checkout/rest.md), + and [HTTP signatures](https://github.com/Universal-Commerce-Protocol/ucp/blob/v2026-08-25/docs/specification/signatures.md). +- [Official UCP Python SDK release](https://github.com/Universal-Commerce-Protocol/python-sdk/releases/tag/v2026-08-25), + installed as `ucp-sdk==0.5.0`; JSON Schema validation complements its models. +- [http-message-signatures v2.0.1](https://github.com/pyauth/http-message-signatures/tree/v2.0.1), + [joserfc 1.7.5](https://pypi.org/project/joserfc/1.7.5/), and + [TRACE v0.10.0 signing/verification](https://github.com/agentrust-io/trace-spec/blob/v0.10.0/src/agentrust_trace/sign.py). + +This demonstrates application-level accountability under configured local keys +and policy. It does **not** establish real merchant identity, buyer consent, +payment authorization by a financial institution, settlement, AP2 compliance, +full UCP conformance/certification, TPM/TEE attestation, key residency, +agent/platform co-location, safe behavior, or runtime integrity. It is not a +claim that #89 is closed or that signed observations independently prove their +truth. All payment, identity, and hardware trust boundaries remain explicit. diff --git a/examples/agentic-commerce-accountability/released_ucp/pyproject.toml b/examples/agentic-commerce-accountability/released_ucp/pyproject.toml new file mode 100644 index 00000000..52f5816e --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/pyproject.toml @@ -0,0 +1,56 @@ +[project] +name = "released-ucp-accountability-example" +version = "0.1.0" +description = "Offline released-UCP purchase authorization and evidence example" +requires-python = ">=3.12,<3.13" +dependencies = [ + "agentrust-trace==0.10.0", + "cryptography==50.0.1", + "http-message-signatures==2.0.1", + "joserfc==1.7.5", + "jsonschema[format]==4.26.0", + "pydantic==2.13.5", + "requests==2.33.0", + "rfc8785==0.1.4", + "ucp-sdk==0.5.0", +] + +[dependency-groups] +dev = [ + "bandit==1.9.4", + "coverage==7.16.0", + "detect-secrets==1.5.0", + "mypy==2.3.1", + "pip-audit==2.10.1", + "pytest==9.1.1", + "ruff==0.16.5", +] + +[tool.uv] +package = false + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["."] +addopts = "-ra --strict-config --strict-markers" + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "B", "UP"] + +[tool.mypy] +python_version = "3.12" +check_untyped_defs = true +ignore_missing_imports = true +files = ["ucp_commerce"] + +[tool.coverage.run] +branch = true +source = ["ucp_commerce"] + +[tool.coverage.report] +show_missing = true +precision = 2 diff --git a/examples/agentic-commerce-accountability/released_ucp/schemas/LICENSE-UCP b/examples/agentic-commerce-accountability/released_ucp/schemas/LICENSE-UCP new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/schemas/LICENSE-UCP @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/examples/agentic-commerce-accountability/released_ucp/schemas/README.md b/examples/agentic-commerce-accountability/released_ucp/schemas/README.md new file mode 100644 index 00000000..811f142c --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/schemas/README.md @@ -0,0 +1,58 @@ +# Pinned UCP schema artifacts + +These are mechanically transformed copies of the Apache-2.0-licensed +[UCP v2026-08-25 source schemas](https://github.com/Universal-Commerce-Protocol/ucp/tree/v2026-08-25/source/schemas), +not a locally invented checkout schema. `LICENSE-UCP` retains the upstream +license. Each output includes a non-normative `$comment` identifying the +transformation. `provenance.json` records the immutable source/tool revisions, +commands, and SHA-256 hashes. +Local Git attributes preserve LF line endings for the hashed schema/license +artifacts and regeneration script, including on autocrlf-enabled checkouts. + +- `checkout-response.json`: base checkout, response/read view. +- `checkout-complete-request.json`: base checkout, request/complete view. + +The released [ucp-schema 1.4.1 resolver](https://github.com/Universal-Commerce-Protocol/ucp-schema/tree/v1.4.1) +bundles references and applies `ucp_request`/`ucp_response` annotations. Only +internal fragment references remain. Runtime validation never retrieves schemas +or profiles from the network. `$id` and advertised schema URLs identify the +upstream vocabulary; they are not runtime fetch instructions. + +## Regeneration + +From the `released_ucp` directory, with `git`, Rust/Cargo, `jq`, and an otherwise +clean checkout of the exact UCP source commit available: + +```sh +cargo install ucp-schema --version 1.4.1 --locked +ucp_source=$(mktemp -d) +git clone --branch v2026-08-25 --depth 1 https://github.com/Universal-Commerce-Protocol/ucp.git "$ucp_source" +./scripts/regenerate_schemas.sh "$ucp_source" +uv run --frozen pytest tests/test_schema.py +``` + +The script rejects another source revision, a dirty checkout, or another +resolver version. It regenerates the artifacts, not their expected hashes; +unexpected changes fail the provenance test and require investigation. Ordinary +tests require neither Rust nor a schema download. Source dependency/toolchain +availability remains a build-environment dependency; this is not a claim of +bit-reproducible tool binaries. + +## Scope and limitations + +Validation applies both the resolved JSON Schema (including supported format +checks) and the released `ucp-sdk==0.5.0` models to the original object, without +returning a coerced replacement. Errors do not echo payload contents. + +The example uses an unextended checkout and an empty payment-instrument list. +It does not implement payment processing, handler-specific schemas, capability +negotiation, or arbitrary extension composition. Open `additionalProperties` +behavior is preserved; this is not a strict replacement schema. Fields omitted +from the resolved request's named properties are not necessarily forbidden by +the open schema. The controller must independently enforce its supported +profile and authorize the exact request. + +Schema/model acceptance does not establish signatures, merchant identity, +spending authority, price arithmetic, lifecycle transitions, payment settlement, +or every prose requirement of UCP. The example's local policy and receipt +checks are distinct from this layer. diff --git a/examples/agentic-commerce-accountability/released_ucp/schemas/checkout-complete-request.json b/examples/agentic-commerce-accountability/released_ucp/schemas/checkout-complete-request.json new file mode 100644 index 00000000..7969bb27 --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/schemas/checkout-complete-request.json @@ -0,0 +1,92 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/checkout.json", + "name": "dev.ucp.shopping.checkout", + "title": "Checkout", + "description": "Base checkout schema. Extensions compose onto this using allOf.", + "type": "object", + "additionalProperties": true, + "properties": { + "signals": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/signals.json", + "title": "Signals", + "description": "Environment data provided by the platform to support authorization and abuse prevention. Values MUST NOT be buyer-asserted claims — platforms provide signals based on direct observation or independently verifiable third-party attestations. All signal keys MUST use reverse-domain naming to ensure provenance and prevent collisions when multiple extensions contribute to the shared namespace.", + "type": "object", + "propertyNames": { + "description": "Reverse-domain identifier (e.g., dev.ucp.buyer_ip, com.example.device_id).", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/reverse_domain_name.json", + "title": "Reverse Domain Name", + "type": "string", + "pattern": "^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$", + "examples": [ + "dev.ucp.shopping.checkout", + "dev.ucp.common.identity_linking", + "com.example.loyalty_gold", + "com.example-shop.checkout", + "com.2example.cart", + "uk.co.example-shop.checkout", + "xn--p1ai.example.checkout" + ] + }, + "properties": { + "dev.ucp.buyer_ip": { + "type": "string", + "description": "Client's IP address (IPv4 or IPv6)." + }, + "dev.ucp.user_agent": { + "type": "string", + "description": "Client's HTTP User-Agent header or equivalent." + } + }, + "additionalProperties": true + }, + "attribution": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/types/attribution.json", + "title": "Attribution", + "description": "Platform-emitted referral and conversion-event context — campaign identifiers, click IDs, source/medium markers, etc. The same parameters platforms communicate via URL query parameters in browser-based flows.", + "type": "object", + "additionalProperties": { + "type": "string", + "description": "URL-style parameter value, encoded as a string. Numeric or boolean values MUST be string-encoded as they would be in a URL query string." + } + }, + "payment": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/payment.json", + "title": "Payment", + "description": "Payment configuration containing handlers.", + "type": "object", + "properties": { + "instruments": { + "type": "array", + "items": { + "title": "Selected Payment Instrument", + "description": "A payment instrument with selection state.", + "allOf": [ + { + "$ref": "#" + }, + { + "type": "object", + "properties": { + "selected": { + "type": "boolean", + "description": "Whether this instrument is selected by the user." + } + } + } + ] + }, + "description": "The payment instruments available for this payment. Each instrument is associated with a specific handler via the handler_id field. Handlers can extend the base payment_instrument schema to add handler-specific fields." + } + } + } + }, + "required": [ + "payment" + ], + "$comment": "Generated from UCP v2026-08-25 by ucp-schema 1.4.1: bundled references and resolved visibility annotations; added this non-normative notice. See provenance.json and LICENSE-UCP." +} diff --git a/examples/agentic-commerce-accountability/released_ucp/schemas/checkout-response.json b/examples/agentic-commerce-accountability/released_ucp/schemas/checkout-response.json new file mode 100644 index 00000000..14f8b1a6 --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/schemas/checkout-response.json @@ -0,0 +1,2063 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/checkout.json", + "name": "dev.ucp.shopping.checkout", + "title": "Checkout", + "description": "Base checkout schema. Extensions compose onto this using allOf.", + "type": "object", + "additionalProperties": true, + "properties": { + "ucp": { + "title": "UCP Checkout Response Schema", + "description": "UCP metadata for checkout responses.", + "allOf": [ + { + "description": "Base UCP metadata with shared properties for all schema types.", + "type": "object", + "properties": { + "version": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$", + "description": "Version identifier in YYYY-MM-DD format." + }, + "map_order": { + "description": "Preferred key-traversal order for sibling registry fields inside the root `ucp` envelope (`services`, `capabilities`, and `payment_handlers`).", + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "status": { + "type": "string", + "enum": [ + "success", + "error" + ], + "default": "success", + "description": "Application-level status of the UCP operation." + }, + "services": { + "type": "object", + "description": "Service registry keyed by reverse-domain name.", + "propertyNames": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/reverse_domain_name.json", + "title": "Reverse Domain Name", + "description": "Reverse-domain identifier used for collision-safe namespacing of capabilities, services, handlers, eligibility claims, and extension-contributed keys. Must contain at least two dot-separated segments (e.g., 'dev.ucp.shopping.checkout', 'com.example.loyalty_gold'). Segments after the first are domain- or identifier-derived: they may contain interior hyphens, may start with a digit, and may contain underscores (e.g., 'com.example-shop.checkout', 'com.2example.cart', 'dev.ucp.common.identity_linking'), but must not start or end with a hyphen. The first segment (the reversed top-level domain) is letters and digits, and may contain interior hyphens to support internationalized (punycode) top-level domains such as 'xn--p1ai'.", + "type": "string", + "pattern": "^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$", + "examples": [ + "dev.ucp.shopping.checkout", + "dev.ucp.common.identity_linking", + "com.example.loyalty_gold", + "com.example-shop.checkout", + "com.2example.cart", + "uk.co.example-shop.checkout", + "xn--p1ai.example.checkout" + ] + }, + "additionalProperties": { + "type": "array", + "items": { + "allOf": [ + { + "type": "object", + "description": "Shared foundation for all UCP entities.", + "properties": { + "version": { + "description": "Entity version in YYYY-MM-DD format.", + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "spec": { + "type": "string", + "format": "uri", + "description": "URL to human-readable specification document." + }, + "schema": { + "type": "string", + "format": "uri", + "description": "URL to JSON Schema defining this entity's structure and payloads." + }, + "id": { + "type": "string", + "description": "Unique identifier for this entity instance. Used to disambiguate when multiple instances exist." + }, + "config": { + "type": "object", + "description": "Entity-specific configuration. Structure defined by each entity's schema.", + "additionalProperties": true + } + }, + "required": [ + "version" + ] + }, + { + "type": "object", + "properties": { + "transport": { + "type": "string", + "enum": [ + "rest", + "mcp", + "a2a", + "embedded" + ], + "description": "Transport protocol for this service binding." + }, + "endpoint": { + "type": "string", + "format": "uri", + "description": "Endpoint URL for this transport binding." + } + }, + "required": [ + "transport" + ] + } + ] + } + } + }, + "capabilities": { + "type": "object", + "description": "Capability registry keyed by reverse-domain name.", + "propertyNames": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/reverse_domain_name.json", + "title": "Reverse Domain Name", + "description": "Reverse-domain identifier used for collision-safe namespacing of capabilities, services, handlers, eligibility claims, and extension-contributed keys. Must contain at least two dot-separated segments (e.g., 'dev.ucp.shopping.checkout', 'com.example.loyalty_gold'). Segments after the first are domain- or identifier-derived: they may contain interior hyphens, may start with a digit, and may contain underscores (e.g., 'com.example-shop.checkout', 'com.2example.cart', 'dev.ucp.common.identity_linking'), but must not start or end with a hyphen. The first segment (the reversed top-level domain) is letters and digits, and may contain interior hyphens to support internationalized (punycode) top-level domains such as 'xn--p1ai'.", + "type": "string", + "pattern": "^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$", + "examples": [ + "dev.ucp.shopping.checkout", + "dev.ucp.common.identity_linking", + "com.example.loyalty_gold", + "com.example-shop.checkout", + "com.2example.cart", + "uk.co.example-shop.checkout", + "xn--p1ai.example.checkout" + ] + }, + "additionalProperties": { + "type": "array", + "items": { + "allOf": [ + { + "type": "object", + "description": "Shared foundation for all UCP entities.", + "properties": { + "version": { + "description": "Entity version in YYYY-MM-DD format.", + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "spec": { + "type": "string", + "format": "uri", + "description": "URL to human-readable specification document." + }, + "schema": { + "type": "string", + "format": "uri", + "description": "URL to JSON Schema defining this entity's structure and payloads." + }, + "id": { + "type": "string", + "description": "Unique identifier for this entity instance. Used to disambiguate when multiple instances exist." + }, + "config": { + "type": "object", + "description": "Entity-specific configuration. Structure defined by each entity's schema.", + "additionalProperties": true + } + }, + "required": [ + "version" + ] + }, + { + "type": "object", + "properties": { + "extends": { + "oneOf": [ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/reverse_domain_name.json", + "title": "Reverse Domain Name", + "description": "Reverse-domain identifier used for collision-safe namespacing of capabilities, services, handlers, eligibility claims, and extension-contributed keys. Must contain at least two dot-separated segments (e.g., 'dev.ucp.shopping.checkout', 'com.example.loyalty_gold'). Segments after the first are domain- or identifier-derived: they may contain interior hyphens, may start with a digit, and may contain underscores (e.g., 'com.example-shop.checkout', 'com.2example.cart', 'dev.ucp.common.identity_linking'), but must not start or end with a hyphen. The first segment (the reversed top-level domain) is letters and digits, and may contain interior hyphens to support internationalized (punycode) top-level domains such as 'xn--p1ai'.", + "type": "string", + "pattern": "^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$", + "examples": [ + "dev.ucp.shopping.checkout", + "dev.ucp.common.identity_linking", + "com.example.loyalty_gold", + "com.example-shop.checkout", + "com.2example.cart", + "uk.co.example-shop.checkout", + "xn--p1ai.example.checkout" + ] + }, + { + "type": "array", + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/reverse_domain_name.json", + "title": "Reverse Domain Name", + "description": "Reverse-domain identifier used for collision-safe namespacing of capabilities, services, handlers, eligibility claims, and extension-contributed keys. Must contain at least two dot-separated segments (e.g., 'dev.ucp.shopping.checkout', 'com.example.loyalty_gold'). Segments after the first are domain- or identifier-derived: they may contain interior hyphens, may start with a digit, and may contain underscores (e.g., 'com.example-shop.checkout', 'com.2example.cart', 'dev.ucp.common.identity_linking'), but must not start or end with a hyphen. The first segment (the reversed top-level domain) is letters and digits, and may contain interior hyphens to support internationalized (punycode) top-level domains such as 'xn--p1ai'.", + "type": "string", + "pattern": "^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$", + "examples": [ + "dev.ucp.shopping.checkout", + "dev.ucp.common.identity_linking", + "com.example.loyalty_gold", + "com.example-shop.checkout", + "com.2example.cart", + "uk.co.example-shop.checkout", + "xn--p1ai.example.checkout" + ] + }, + "minItems": 1 + } + ], + "description": "Parent capability(s) this extends. Present for extensions, absent for root capabilities. Use array for multi-parent extensions." + } + } + } + ] + } + } + }, + "payment_handlers": { + "type": "object", + "description": "Payment handler registry keyed by reverse-domain name.", + "propertyNames": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/reverse_domain_name.json", + "title": "Reverse Domain Name", + "description": "Reverse-domain identifier used for collision-safe namespacing of capabilities, services, handlers, eligibility claims, and extension-contributed keys. Must contain at least two dot-separated segments (e.g., 'dev.ucp.shopping.checkout', 'com.example.loyalty_gold'). Segments after the first are domain- or identifier-derived: they may contain interior hyphens, may start with a digit, and may contain underscores (e.g., 'com.example-shop.checkout', 'com.2example.cart', 'dev.ucp.common.identity_linking'), but must not start or end with a hyphen. The first segment (the reversed top-level domain) is letters and digits, and may contain interior hyphens to support internationalized (punycode) top-level domains such as 'xn--p1ai'.", + "type": "string", + "pattern": "^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$", + "examples": [ + "dev.ucp.shopping.checkout", + "dev.ucp.common.identity_linking", + "com.example.loyalty_gold", + "com.example-shop.checkout", + "com.2example.cart", + "uk.co.example-shop.checkout", + "xn--p1ai.example.checkout" + ] + }, + "additionalProperties": { + "type": "array", + "items": { + "allOf": [ + { + "type": "object", + "description": "Shared foundation for all UCP entities.", + "properties": { + "version": { + "description": "Entity version in YYYY-MM-DD format.", + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "spec": { + "type": "string", + "format": "uri", + "description": "URL to human-readable specification document." + }, + "schema": { + "type": "string", + "format": "uri", + "description": "URL to JSON Schema defining this entity's structure and payloads." + }, + "id": { + "type": "string", + "description": "Unique identifier for this entity instance. Used to disambiguate when multiple instances exist." + }, + "config": { + "type": "object", + "description": "Entity-specific configuration. Structure defined by each entity's schema.", + "additionalProperties": true + } + }, + "required": [ + "version" + ] + }, + { + "type": "object", + "required": [ + "id" + ] + }, + { + "type": "object", + "properties": { + "available_instruments": { + "type": "array", + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/available_payment_instrument.json", + "title": "Available Payment Instrument", + "description": "An instrument type available from a payment handler with optional constraints.", + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The instrument type identifier (e.g., 'card', 'gift_card'). References an instrument schema's type constant." + }, + "constraints": { + "description": "A Constraint Expression describing the instrument this entry makes available. Keys in `properties` name members of the `constraint_target` declared by the instrument schema for this `type`. Requirements on submitted request data belong in `ucp.request_constraints` instead.", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/constraint_expression.json", + "title": "Constraint Expression", + "type": "object", + "properties": { + "required": { + "type": "array", + "description": "Property names required by the constrained object. Must be non-empty: an empty array applies no constraint.", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "description": "Constraints keyed by property name. Must be non-empty: an empty object applies no constraint.", + "minProperties": 1, + "additionalProperties": { + "oneOf": [ + { + "$ref": "#" + }, + { + "type": "object", + "description": "A Value Constraint containing `enum`, `const`, or both.", + "anyOf": [ + { + "required": [ + "enum" + ] + }, + { + "required": [ + "const" + ] + } + ], + "properties": { + "enum": { + "type": "array", + "description": "A non-empty array of unique JSON values.", + "minItems": 1, + "uniqueItems": true + }, + "const": {} + }, + "additionalProperties": false + } + ] + } + }, + "anyOf": { + "type": "array", + "description": "Alternative Object Constraints. The constrained object must satisfy at least one. A branch must be non-empty: an empty branch is satisfied by every object and neutralizes the alternation.", + "minItems": 1, + "items": { + "$ref": "#", + "minProperties": 1 + } + } + }, + "additionalProperties": false, + "$defs": { + "value_constraint": { + "type": "object", + "description": "A Value Constraint containing `enum`, `const`, or both.", + "anyOf": [ + { + "required": [ + "enum" + ] + }, + { + "required": [ + "const" + ] + } + ], + "properties": { + "enum": { + "type": "array", + "description": "A non-empty array of unique JSON values.", + "minItems": 1, + "uniqueItems": true + }, + "const": {} + }, + "additionalProperties": false + } + } + } + }, + "required": [ + "type" + ] + }, + "description": "Instrument types this handler supports, with optional constraints. When absent, every instrument should be considered available.", + "minItems": 1 + } + } + } + ] + } + } + } + }, + "required": [ + "version" + ] + }, + { + "properties": { + "services": { + "additionalProperties": { + "items": { + "title": "Service (Response Schema)", + "description": "Service binding in API responses. Includes per-resource transport configuration via typed config.", + "allOf": [ + { + "allOf": [ + { + "type": "object", + "description": "Shared foundation for all UCP entities.", + "properties": { + "version": { + "description": "Entity version in YYYY-MM-DD format.", + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "spec": { + "type": "string", + "format": "uri", + "description": "URL to human-readable specification document." + }, + "schema": { + "type": "string", + "format": "uri", + "description": "URL to JSON Schema defining this entity's structure and payloads." + }, + "id": { + "type": "string", + "description": "Unique identifier for this entity instance. Used to disambiguate when multiple instances exist." + }, + "config": { + "type": "object", + "description": "Entity-specific configuration. Structure defined by each entity's schema.", + "additionalProperties": true + } + }, + "required": [ + "version" + ] + }, + { + "type": "object", + "properties": { + "transport": { + "type": "string", + "enum": [ + "rest", + "mcp", + "a2a", + "embedded" + ], + "description": "Transport protocol for this service binding." + }, + "endpoint": { + "type": "string", + "format": "uri", + "description": "Endpoint URL for this transport binding." + } + }, + "required": [ + "transport" + ] + } + ] + }, + { + "anyOf": [ + { + "properties": { + "transport": { + "const": "rest" + } + } + }, + { + "properties": { + "transport": { + "const": "mcp" + } + } + }, + { + "properties": { + "transport": { + "const": "a2a" + } + } + }, + { + "properties": { + "transport": { + "const": "embedded" + }, + "config": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/transports/embedded_config.json", + "title": "Embedded Transport Config", + "description": "Per-session configuration for embedded transport binding. Allows businesses to vary EP availability and delegations based on cart contents, agent authorization, or policy.", + "type": "object", + "properties": { + "delegate": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Delegations the business allows. At service-level, declares available delegations. In UCP responses, confirms accepted delegations for this session." + }, + "color_scheme": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "light", + "dark" + ] + }, + "description": "Color schemes the business supports. Hosts use ec_color_scheme query parameter to request a scheme from this list." + } + } + } + } + } + ] + } + ] + } + } + }, + "capabilities": { + "additionalProperties": { + "items": { + "title": "Capability (Response Schema)", + "description": "Capability reference in responses. Only name/version required to confirm active capabilities.", + "allOf": [ + { + "allOf": [ + { + "type": "object", + "description": "Shared foundation for all UCP entities.", + "properties": { + "version": { + "description": "Entity version in YYYY-MM-DD format.", + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "spec": { + "type": "string", + "format": "uri", + "description": "URL to human-readable specification document." + }, + "schema": { + "type": "string", + "format": "uri", + "description": "URL to JSON Schema defining this entity's structure and payloads." + }, + "id": { + "type": "string", + "description": "Unique identifier for this entity instance. Used to disambiguate when multiple instances exist." + }, + "config": { + "type": "object", + "description": "Entity-specific configuration. Structure defined by each entity's schema.", + "additionalProperties": true + } + }, + "required": [ + "version" + ] + }, + { + "type": "object", + "properties": { + "extends": { + "oneOf": [ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/reverse_domain_name.json", + "title": "Reverse Domain Name", + "description": "Reverse-domain identifier used for collision-safe namespacing of capabilities, services, handlers, eligibility claims, and extension-contributed keys. Must contain at least two dot-separated segments (e.g., 'dev.ucp.shopping.checkout', 'com.example.loyalty_gold'). Segments after the first are domain- or identifier-derived: they may contain interior hyphens, may start with a digit, and may contain underscores (e.g., 'com.example-shop.checkout', 'com.2example.cart', 'dev.ucp.common.identity_linking'), but must not start or end with a hyphen. The first segment (the reversed top-level domain) is letters and digits, and may contain interior hyphens to support internationalized (punycode) top-level domains such as 'xn--p1ai'.", + "type": "string", + "pattern": "^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$", + "examples": [ + "dev.ucp.shopping.checkout", + "dev.ucp.common.identity_linking", + "com.example.loyalty_gold", + "com.example-shop.checkout", + "com.2example.cart", + "uk.co.example-shop.checkout", + "xn--p1ai.example.checkout" + ] + }, + { + "type": "array", + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/reverse_domain_name.json", + "title": "Reverse Domain Name", + "description": "Reverse-domain identifier used for collision-safe namespacing of capabilities, services, handlers, eligibility claims, and extension-contributed keys. Must contain at least two dot-separated segments (e.g., 'dev.ucp.shopping.checkout', 'com.example.loyalty_gold'). Segments after the first are domain- or identifier-derived: they may contain interior hyphens, may start with a digit, and may contain underscores (e.g., 'com.example-shop.checkout', 'com.2example.cart', 'dev.ucp.common.identity_linking'), but must not start or end with a hyphen. The first segment (the reversed top-level domain) is letters and digits, and may contain interior hyphens to support internationalized (punycode) top-level domains such as 'xn--p1ai'.", + "type": "string", + "pattern": "^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$", + "examples": [ + "dev.ucp.shopping.checkout", + "dev.ucp.common.identity_linking", + "com.example.loyalty_gold", + "com.example-shop.checkout", + "com.2example.cart", + "uk.co.example-shop.checkout", + "xn--p1ai.example.checkout" + ] + }, + "minItems": 1 + } + ], + "description": "Parent capability(s) this extends. Present for extensions, absent for root capabilities. Use array for multi-parent extensions." + } + } + } + ] + } + ] + } + } + }, + "payment_handlers": { + "additionalProperties": { + "items": { + "title": "Payment Handler (Response Schema)", + "description": "Handler reference in responses. May include full config state for runtime usage of the handler.", + "allOf": [ + { + "allOf": [ + { + "type": "object", + "description": "Shared foundation for all UCP entities.", + "properties": { + "version": { + "description": "Entity version in YYYY-MM-DD format.", + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}$" + }, + "spec": { + "type": "string", + "format": "uri", + "description": "URL to human-readable specification document." + }, + "schema": { + "type": "string", + "format": "uri", + "description": "URL to JSON Schema defining this entity's structure and payloads." + }, + "id": { + "type": "string", + "description": "Unique identifier for this entity instance. Used to disambiguate when multiple instances exist." + }, + "config": { + "type": "object", + "description": "Entity-specific configuration. Structure defined by each entity's schema.", + "additionalProperties": true + } + }, + "required": [ + "version" + ] + }, + { + "type": "object", + "required": [ + "id" + ] + }, + { + "type": "object", + "properties": { + "available_instruments": { + "type": "array", + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/available_payment_instrument.json", + "title": "Available Payment Instrument", + "description": "An instrument type available from a payment handler with optional constraints.", + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "The instrument type identifier (e.g., 'card', 'gift_card'). References an instrument schema's type constant." + }, + "constraints": { + "description": "A Constraint Expression describing the instrument this entry makes available. Keys in `properties` name members of the `constraint_target` declared by the instrument schema for this `type`. Requirements on submitted request data belong in `ucp.request_constraints` instead.", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/constraint_expression.json", + "title": "Constraint Expression", + "type": "object", + "properties": { + "required": { + "type": "array", + "description": "Property names required by the constrained object. Must be non-empty: an empty array applies no constraint.", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string" + } + }, + "properties": { + "type": "object", + "description": "Constraints keyed by property name. Must be non-empty: an empty object applies no constraint.", + "minProperties": 1, + "additionalProperties": { + "oneOf": [ + { + "$ref": "#" + }, + { + "type": "object", + "description": "A Value Constraint containing `enum`, `const`, or both.", + "anyOf": [ + { + "required": [ + "enum" + ] + }, + { + "required": [ + "const" + ] + } + ], + "properties": { + "enum": { + "type": "array", + "description": "A non-empty array of unique JSON values.", + "minItems": 1, + "uniqueItems": true + }, + "const": {} + }, + "additionalProperties": false + } + ] + } + }, + "anyOf": { + "type": "array", + "description": "Alternative Object Constraints. The constrained object must satisfy at least one. A branch must be non-empty: an empty branch is satisfied by every object and neutralizes the alternation.", + "minItems": 1, + "items": { + "$ref": "#", + "minProperties": 1 + } + } + }, + "additionalProperties": false, + "$defs": { + "value_constraint": { + "type": "object", + "description": "A Value Constraint containing `enum`, `const`, or both.", + "anyOf": [ + { + "required": [ + "enum" + ] + }, + { + "required": [ + "const" + ] + } + ], + "properties": { + "enum": { + "type": "array", + "description": "A non-empty array of unique JSON values.", + "minItems": 1, + "uniqueItems": true + }, + "const": {} + }, + "additionalProperties": false + } + } + } + }, + "required": [ + "type" + ] + }, + "description": "Instrument types this handler supports, with optional constraints. When absent, every instrument should be considered available.", + "minItems": 1 + } + } + } + ] + } + ] + } + } + } + }, + "required": [ + "payment_handlers" + ] + } + ] + }, + "id": { + "type": "string", + "description": "Unique identifier of the checkout session." + }, + "line_items": { + "type": "array", + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/types/line_item.json", + "title": "Line Item", + "type": "object", + "description": "Line item object. Expected to use the currency of the parent object.", + "properties": { + "id": { + "type": "string" + }, + "item": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/types/item.json", + "title": "Item", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The product identifier, often the SKU, required to resolve the product details associated with this line item. Should be recognized by both the Platform, and the Business." + }, + "title": { + "type": "string", + "description": "Product title." + }, + "price": { + "description": "Unit price in ISO 4217 minor units. Price is the amount per one whole `quantity_unit.unit` (for example, per lb or per hour); when `quantity_unit` is absent, it is per `each`.", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/amount.json", + "title": "Amount", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "quantity_unit": { + "description": "Sale basis this item's `quantity` is denominated in. On an authoritative Business response, absence encodes the default `each` machine identity (`C62`, 0); the Business MUST include this descriptor for every non-`each` response. On Platform requests, omission makes no assertion: the Business interprets `quantity` using the item's authoritative sale basis. If the Platform includes this descriptor, it asserts the unit-descriptor machine identity. The Business MUST compare that machine identity (`unit`, effective `scale`), ignore `display_text` and `increment`, and resolve a mismatch by conversion surfaced as a visible line revision with a warning, or by rejection with a recoverable business outcome; silent reinterpretation is forbidden. An explicit `C62` descriptor at effective scale 0 matches an authoritative basis represented by an absent descriptor.", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/quantity_unit.json", + "title": "Quantity Unit", + "allOf": [ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/unit.json", + "title": "Unit", + "description": "A reusable unit descriptor for quantities and measures. Its unit-descriptor machine identity is (`unit`, effective `scale`), where effective `scale` is the provided `scale` or 0; `display_text` is excluded.", + "type": "object", + "properties": { + "unit": { + "type": "string", + "description": "Stable machine identifier. The Business SHOULD use the exact UN/CEFACT Rec20 Common Code when one accurately identifies the unit. Otherwise, the Business MAY use a custom unit identifier and MUST use it consistently for the same unit. The Platform MUST treat an unrecognized identifier as opaque." + }, + "scale": { + "type": "integer", + "minimum": 0, + "maximum": 15, + "default": 0, + "description": "One step equals `10^-scale` of `unit`. When `unit` is `C62`, `scale`, if present, MUST be 0. The maximum of 15 is derived from the interoperable integer range: at scale 16 a single whole unit (10^16 steps) is no longer representable, so larger scales cannot denominate one unit of their own basis. Businesses needing finer granularity use a smaller unit." + }, + "display_text": { + "type": "string", + "description": "Required printable unit label provided by the Business. The Platform MUST use it when it does not recognize `unit`; for a recognized UN/CEFACT Rec 20 Common Code, the Platform MAY substitute its own localized label. It does not participate in unit identity or mismatch comparison." + } + }, + "allOf": [ + { + "if": { + "properties": { + "unit": { + "const": "C62" + } + }, + "required": [ + "unit" + ] + }, + "then": { + "properties": { + "scale": { + "const": 0 + } + } + } + } + ], + "required": [ + "unit", + "display_text" + ] + }, + { + "type": "object", + "properties": { + "increment": { + "type": "integer", + "minimum": 1, + "default": 1, + "description": "Ordering granularity, denominated in steps: the Business sells this item in integer multiples of `increment` steps. Its effective value is the provided value or 1. Advisory merchandising policy, not a representational bound: Platform-authored quantities SHOULD be integer multiples of the effective increment; the Business MAY accept, revise, or reject an off-increment request with a recoverable business outcome and MUST NOT silently reinterpret it. Business-authored quantities (checkout revisions, fulfillment events, adjustments) are bounded only by `scale`." + } + } + } + ] + }, + "unit_price": { + "description": "Pricing basis for this item. On an authoritative Business response, the Business MUST include `unit_price` on every line whose pricing basis differs from its sale basis (for example, priced per pound but sold per `each`); presence on a line marks the rate as transactional rather than display-only. When the pricing basis is the sale basis, `item.price` fully denominates the charge and this field MAY be omitted.", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/types/unit_price.json", + "title": "Unit Price", + "type": "object", + "properties": { + "amount": { + "description": "Unit price in ISO 4217 minor units. After satisfying the same-unit invariant, the Business MUST compute the comparator as `(price.amount / (measure.value × 10^-measure.scale)) × (reference.value × 10^-reference.scale)` and round it once to ISO 4217 minor units according to its pricing rules. The returned `unit_price.amount` is authoritative; the Platform MUST NOT recompute or substitute its own result.", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/amount.json", + "title": "Amount", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "currency": { + "type": "string", + "pattern": "^[A-Z]{3}$", + "description": "ISO 4217 currency code." + }, + "measure": { + "description": "Product quantity in packaging/content (for example, a 750 mL bottle), distinct from `quantity_unit`, which defines the sale basis. Its integer `value` MUST be at least 1.", + "allOf": [ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/measure.json", + "title": "Measure", + "description": "A measure composed of an integer value and a unit descriptor. Its value is the integer count of `10^-scale` units of `unit`.", + "allOf": [ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/unit.json", + "title": "Unit", + "description": "A reusable unit descriptor for quantities and measures. Its unit-descriptor machine identity is (`unit`, effective `scale`), where effective `scale` is the provided `scale` or 0; `display_text` is excluded.", + "type": "object", + "properties": { + "unit": { + "type": "string", + "description": "Stable machine identifier. The Business SHOULD use the exact UN/CEFACT Rec20 Common Code when one accurately identifies the unit. Otherwise, the Business MAY use a custom unit identifier and MUST use it consistently for the same unit. The Platform MUST treat an unrecognized identifier as opaque." + }, + "scale": { + "type": "integer", + "minimum": 0, + "maximum": 15, + "default": 0, + "description": "One step equals `10^-scale` of `unit`. When `unit` is `C62`, `scale`, if present, MUST be 0. The maximum of 15 is derived from the interoperable integer range: at scale 16 a single whole unit (10^16 steps) is no longer representable, so larger scales cannot denominate one unit of their own basis. Businesses needing finer granularity use a smaller unit." + }, + "display_text": { + "type": "string", + "description": "Required printable unit label provided by the Business. The Platform MUST use it when it does not recognize `unit`; for a recognized UN/CEFACT Rec 20 Common Code, the Platform MAY substitute its own localized label. It does not participate in unit identity or mismatch comparison." + } + }, + "allOf": [ + { + "if": { + "properties": { + "unit": { + "const": "C62" + } + }, + "required": [ + "unit" + ] + }, + "then": { + "properties": { + "scale": { + "const": 0 + } + } + } + } + ], + "required": [ + "unit", + "display_text" + ] + }, + { + "type": "object", + "properties": { + "value": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "Integer count of `10^-scale` units of `unit`." + } + }, + "required": [ + "value" + ] + } + ] + }, + { + "type": "object", + "properties": { + "value": { + "minimum": 1 + } + } + } + ] + }, + "reference": { + "description": "Denominator for unit price display (for example, per 100 mL or per 1 kg). Its integer `value` MUST be at least 1.", + "allOf": [ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/measure.json", + "title": "Measure", + "description": "A measure composed of an integer value and a unit descriptor. Its value is the integer count of `10^-scale` units of `unit`.", + "allOf": [ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/unit.json", + "title": "Unit", + "description": "A reusable unit descriptor for quantities and measures. Its unit-descriptor machine identity is (`unit`, effective `scale`), where effective `scale` is the provided `scale` or 0; `display_text` is excluded.", + "type": "object", + "properties": { + "unit": { + "type": "string", + "description": "Stable machine identifier. The Business SHOULD use the exact UN/CEFACT Rec20 Common Code when one accurately identifies the unit. Otherwise, the Business MAY use a custom unit identifier and MUST use it consistently for the same unit. The Platform MUST treat an unrecognized identifier as opaque." + }, + "scale": { + "type": "integer", + "minimum": 0, + "maximum": 15, + "default": 0, + "description": "One step equals `10^-scale` of `unit`. When `unit` is `C62`, `scale`, if present, MUST be 0. The maximum of 15 is derived from the interoperable integer range: at scale 16 a single whole unit (10^16 steps) is no longer representable, so larger scales cannot denominate one unit of their own basis. Businesses needing finer granularity use a smaller unit." + }, + "display_text": { + "type": "string", + "description": "Required printable unit label provided by the Business. The Platform MUST use it when it does not recognize `unit`; for a recognized UN/CEFACT Rec 20 Common Code, the Platform MAY substitute its own localized label. It does not participate in unit identity or mismatch comparison." + } + }, + "allOf": [ + { + "if": { + "properties": { + "unit": { + "const": "C62" + } + }, + "required": [ + "unit" + ] + }, + "then": { + "properties": { + "scale": { + "const": 0 + } + } + } + } + ], + "required": [ + "unit", + "display_text" + ] + }, + { + "type": "object", + "properties": { + "value": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "Integer count of `10^-scale` units of `unit`." + } + }, + "required": [ + "value" + ] + } + ] + }, + { + "type": "object", + "properties": { + "value": { + "minimum": 1 + } + } + } + ] + } + }, + "required": [ + "amount", + "currency", + "measure", + "reference" + ] + }, + "image_url": { + "type": "string", + "description": "Product image URI.", + "format": "uri" + } + }, + "required": [ + "id", + "title", + "price" + ] + }, + "quantity": { + "type": "integer", + "description": "Always an integer step count. On Platform requests, steps use the item's Business-authoritative sale basis; omitting `item.quantity_unit` makes no assertion and does not imply `each`. On Business responses, `item.quantity_unit` describes the basis; if absent, it encodes the `each` machine identity (`C62`, 0) and `quantity` counts whole items.", + "minimum": 1, + "maximum": 9007199254740991 + }, + "totals": { + "type": "array", + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/total.json", + "title": "Total", + "description": "A cost breakdown entry with a category, amount, and optional display text.", + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Cost category. Well-known values: subtotal, items_discount, discount, fulfillment, tax, fee, total. Businesses MAY use additional values." + }, + "display_text": { + "type": "string", + "description": "Text to display against the amount. Should reflect appropriate method (e.g., 'Shipping', 'Delivery')." + }, + "amount": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/signed_amount.json", + "title": "Signed Amount", + "description": "Monetary amount in the currency's minor unit as defined by ISO 4217. Refer to the currency's exponent to determine minor-to-major ratio (e.g., 2 for USD, 0 for JPY, 3 for KWD). May be negative — the sign is intrinsic to the value (e.g., discounts are negative, charges are positive).", + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": [ + "discount", + "items_discount" + ] + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "amount": { + "exclusiveMaximum": 0 + } + } + } + }, + { + "if": { + "properties": { + "type": { + "enum": [ + "subtotal", + "fulfillment", + "tax", + "fee" + ] + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "amount": { + "minimum": 0 + } + } + } + } + ], + "required": [ + "type", + "amount" + ] + }, + "description": "Line item totals breakdown." + }, + "parent_id": { + "type": "string", + "description": "Parent line item identifier for any nested structures." + } + }, + "required": [ + "id", + "item", + "quantity", + "totals" + ] + }, + "description": "List of line items being checked out." + }, + "buyer": { + "description": "Representation of the buyer.", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/types/buyer.json", + "title": "Buyer", + "type": "object", + "additionalProperties": true, + "properties": { + "first_name": { + "type": "string", + "description": "First name of the buyer." + }, + "last_name": { + "type": "string", + "description": "Last name of the buyer." + }, + "email": { + "type": "string", + "description": "Email of the buyer." + }, + "phone_number": { + "type": "string", + "description": "E.164 standard." + } + } + }, + "context": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/context.json", + "title": "Context", + "description": "Provisional buyer signals for relevance and localization—not authoritative data. Businesses SHOULD use these values when verified inputs (e.g., shipping address) are absent, and MAY ignore or down-rank them if inconsistent with higher-confidence signals (authenticated account, risk detection) or regulatory constraints (export controls). Eligibility and policy enforcement MUST occur at checkout time using binding transaction data. Context SHOULD be non-identifying and can be disclosed progressively—coarse signals early, finer resolution as the session progresses. Higher-resolution data (shipping address, billing address) supersedes context.", + "type": "object", + "additionalProperties": true, + "allOf": [ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/locality.json", + "title": "Locality", + "description": "A coarse geographic location — country, region, and postal code. A lightweight alternative to a full postal address.", + "type": "object", + "additionalProperties": true, + "properties": { + "address_country": { + "type": "string", + "description": "The country, as a 2-letter ISO 3166-1 alpha-2 code (e.g. \"US\"). A 3-letter alpha-3 code or full country name MAY also be used." + }, + "address_region": { + "type": "string", + "description": "The first-level administrative region within the country (e.g. a state or province such as California)." + }, + "postal_code": { + "type": "string", + "description": "The postal code (e.g. \"94043\")." + } + } + }, + { + "type": "object", + "additionalProperties": true, + "properties": { + "location": { + "type": "string", + "description": "Stable, opaque identifier for a Location in the Business's namespace. This provisional, non-binding hint is distinct from the Buyer's locality. The operation specification or an active capability/extension defines its effects. A common example in retail shopping is the default home store ID selected and saved by the user when purchasing groceries." + }, + "intent": { + "type": "string", + "description": "Background context describing buyer's intent (e.g., 'looking for a gift under $50', 'need something durable for outdoor use'). Informs relevance, recommendations, and personalization." + }, + "language": { + "type": "string", + "description": "Preferred language for content. Use IETF BCP 47 language tags (e.g., 'en', 'fr-CA', 'zh-Hans'). For REST, equivalent to Accept-Language header—platforms SHOULD fall back to Accept-Language when this field is absent; when provided, overrides Accept-Language. Businesses MAY return content in a different language if unavailable." + }, + "currency": { + "type": "string", + "description": "Preferred currency (ISO 4217, e.g., 'EUR', 'USD'). Businesses determine presentment currency from context and authoritative signals; this hint MAY inform selection in multi-currency markets. Also serves as the denomination for price filter values — platforms SHOULD include this field when sending price filters. Response prices include explicit currency confirming the resolution." + }, + "eligibility": { + "type": "array", + "description": "Buyer claims about eligible benefits such as loyalty membership, payment instrument perks, and similar. Recognized claims MAY inform the Business response (e.g., member-only product availability, adjusted pricing in catalog, provisional discounts at cart or checkout). Businesses MUST ignore unrecognized values without error. Values MUST use reverse-domain naming (e.g., 'com.example.loyalty_gold', 'org.school.student') and MUST be non-identifying.", + "uniqueItems": true, + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/reverse_domain_name.json", + "title": "Reverse Domain Name", + "description": "Reverse-domain identifier used for collision-safe namespacing of capabilities, services, handlers, eligibility claims, and extension-contributed keys. Must contain at least two dot-separated segments (e.g., 'dev.ucp.shopping.checkout', 'com.example.loyalty_gold'). Segments after the first are domain- or identifier-derived: they may contain interior hyphens, may start with a digit, and may contain underscores (e.g., 'com.example-shop.checkout', 'com.2example.cart', 'dev.ucp.common.identity_linking'), but must not start or end with a hyphen. The first segment (the reversed top-level domain) is letters and digits, and may contain interior hyphens to support internationalized (punycode) top-level domains such as 'xn--p1ai'.", + "type": "string", + "pattern": "^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$", + "examples": [ + "dev.ucp.shopping.checkout", + "dev.ucp.common.identity_linking", + "com.example.loyalty_gold", + "com.example-shop.checkout", + "com.2example.cart", + "uk.co.example-shop.checkout", + "xn--p1ai.example.checkout" + ] + } + }, + "payment": { + "type": "array", + "description": "Buyer-preferred payment handlers in priority order (most preferred first). Each entry names a handler advertised in the Business profile's `ucp.payment_handlers`, optionally narrowed to preferred instrument types. The Business SHOULD use it to preselect or prioritize the handler (and type, when given) and MAY ignore unavailable or ineligible entries; unrecognized values MUST be ignored without error.", + "items": { + "type": "object", + "properties": { + "handler": { + "description": "Handler registry key advertised in the Business profile's `ucp.payment_handlers`.", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/reverse_domain_name.json", + "title": "Reverse Domain Name", + "type": "string", + "pattern": "^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$", + "examples": [ + "dev.ucp.shopping.checkout", + "dev.ucp.common.identity_linking", + "com.example.loyalty_gold", + "com.example-shop.checkout", + "com.2example.cart", + "uk.co.example-shop.checkout", + "xn--p1ai.example.checkout" + ] + }, + "types": { + "type": "array", + "description": "Optional preferred instrument types for this handler, in priority order, aligned with the handler's advertised `payment_instrument.type` values (for example `card` or `bank`). Unrecognized values MUST be ignored.", + "items": { + "type": "string" + } + } + }, + "required": [ + "handler" + ] + } + } + } + } + ] + }, + "signals": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/signals.json", + "title": "Signals", + "description": "Environment data provided by the platform to support authorization and abuse prevention. Values MUST NOT be buyer-asserted claims — platforms provide signals based on direct observation or independently verifiable third-party attestations. All signal keys MUST use reverse-domain naming to ensure provenance and prevent collisions when multiple extensions contribute to the shared namespace.", + "type": "object", + "propertyNames": { + "description": "Reverse-domain identifier (e.g., dev.ucp.buyer_ip, com.example.device_id).", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/reverse_domain_name.json", + "title": "Reverse Domain Name", + "type": "string", + "pattern": "^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$", + "examples": [ + "dev.ucp.shopping.checkout", + "dev.ucp.common.identity_linking", + "com.example.loyalty_gold", + "com.example-shop.checkout", + "com.2example.cart", + "uk.co.example-shop.checkout", + "xn--p1ai.example.checkout" + ] + }, + "properties": { + "dev.ucp.buyer_ip": { + "type": "string", + "description": "Client's IP address (IPv4 or IPv6)." + }, + "dev.ucp.user_agent": { + "type": "string", + "description": "Client's HTTP User-Agent header or equivalent." + } + }, + "additionalProperties": true + }, + "attribution": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/types/attribution.json", + "title": "Attribution", + "description": "Platform-emitted referral and conversion-event context — campaign identifiers, click IDs, source/medium markers, etc. The same parameters platforms communicate via URL query parameters in browser-based flows.", + "type": "object", + "additionalProperties": { + "type": "string", + "description": "URL-style parameter value, encoded as a string. Numeric or boolean values MUST be string-encoded as they would be in a URL query string." + } + }, + "status": { + "type": "string", + "enum": [ + "incomplete", + "requires_escalation", + "ready_for_complete", + "complete_in_progress", + "completed", + "canceled" + ], + "description": "Checkout state indicating the current phase and required processing. See Checkout Status lifecycle documentation for state transition details." + }, + "currency": { + "type": "string", + "description": "ISO 4217 currency code reflecting the merchant's market determination. Derived from address, context, and geo IP—buyers provide signals, merchants determine currency." + }, + "totals": { + "description": "Different cart totals.", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/totals.json", + "title": "Totals", + "type": "array", + "items": { + "allOf": [ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/total.json", + "title": "Total", + "description": "A cost breakdown entry with a category, amount, and optional display text.", + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Cost category. Well-known values: subtotal, items_discount, discount, fulfillment, tax, fee, total. Businesses MAY use additional values." + }, + "display_text": { + "type": "string", + "description": "Text to display against the amount. Should reflect appropriate method (e.g., 'Shipping', 'Delivery')." + }, + "amount": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/signed_amount.json", + "title": "Signed Amount", + "description": "Monetary amount in the currency's minor unit as defined by ISO 4217. Refer to the currency's exponent to determine minor-to-major ratio (e.g., 2 for USD, 0 for JPY, 3 for KWD). May be negative — the sign is intrinsic to the value (e.g., discounts are negative, charges are positive).", + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "allOf": [ + { + "if": { + "properties": { + "type": { + "enum": [ + "discount", + "items_discount" + ] + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "amount": { + "exclusiveMaximum": 0 + } + } + } + }, + { + "if": { + "properties": { + "type": { + "enum": [ + "subtotal", + "fulfillment", + "tax", + "fee" + ] + } + }, + "required": [ + "type" + ] + }, + "then": { + "properties": { + "amount": { + "minimum": 0 + } + } + } + } + ], + "required": [ + "type", + "amount" + ] + }, + { + "type": "object", + "properties": { + "lines": { + "type": "array", + "items": { + "type": "object", + "properties": { + "display_text": { + "type": "string", + "description": "Human-readable label for this sub-line." + }, + "amount": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/signed_amount.json", + "title": "Signed Amount", + "description": "Monetary amount in the currency's minor unit as defined by ISO 4217. Refer to the currency's exponent to determine minor-to-major ratio (e.g., 2 for USD, 0 for JPY, 3 for KWD). May be negative — the sign is intrinsic to the value (e.g., discounts are negative, charges are positive).", + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "description": "Sub-line entry. Additional metadata MAY be included.", + "required": [ + "display_text", + "amount" + ] + }, + "description": "Optional itemized breakdown. The parent entry is always rendered; lines are supplementary. Sum of line amounts MUST equal the parent entry amount." + } + } + }, + { + "if": { + "properties": { + "type": { + "not": { + "enum": [ + "subtotal", + "items_discount", + "discount", + "fulfillment", + "tax", + "fee", + "total" + ] + } + } + }, + "required": [ + "type" + ] + }, + "then": { + "required": [ + "display_text" + ] + } + } + ] + }, + "allOf": [ + { + "contains": { + "properties": { + "type": { + "const": "subtotal" + } + }, + "required": [ + "type" + ] + }, + "minContains": 1, + "maxContains": 1 + }, + { + "contains": { + "properties": { + "type": { + "const": "total" + } + }, + "required": [ + "type" + ] + }, + "minContains": 1, + "maxContains": 1 + } + ] + }, + "actions": { + "description": "Outstanding extension-defined Actions for this checkout.", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/actions.json", + "title": "Actions", + "$defs": { + "instance": { + "type": "object", + "description": "Common fields for one outstanding Action instance are id and optional config. The extension declaring the Action type defines type-specific processing data under config. Additional properties are permitted for forward compatibility.", + "additionalProperties": true, + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Identifier for this Action instance." + }, + "config": { + "type": "object", + "description": "Configuration defined by the extension that declares this Action type.", + "additionalProperties": true + } + }, + "required": [ + "id" + ] + } + }, + "type": "object", + "propertyNames": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/reverse_domain_name.json", + "title": "Reverse Domain Name", + "description": "Reverse-domain identifier used for collision-safe namespacing of capabilities, services, handlers, eligibility claims, and extension-contributed keys. Must contain at least two dot-separated segments (e.g., 'dev.ucp.shopping.checkout', 'com.example.loyalty_gold'). Segments after the first are domain- or identifier-derived: they may contain interior hyphens, may start with a digit, and may contain underscores (e.g., 'com.example-shop.checkout', 'com.2example.cart', 'dev.ucp.common.identity_linking'), but must not start or end with a hyphen. The first segment (the reversed top-level domain) is letters and digits, and may contain interior hyphens to support internationalized (punycode) top-level domains such as 'xn--p1ai'.", + "type": "string", + "pattern": "^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$", + "examples": [ + "dev.ucp.shopping.checkout", + "dev.ucp.common.identity_linking", + "com.example.loyalty_gold", + "com.example-shop.checkout", + "com.2example.cart", + "uk.co.example-shop.checkout", + "xn--p1ai.example.checkout" + ] + }, + "additionalProperties": { + "type": "array", + "description": "Non-empty instances of one Action type. JSON preserves array order; the declaring extension defines whether order has processing semantics.", + "minItems": 1, + "items": { + "type": "object", + "description": "Common fields for one outstanding Action instance are id and optional config. The extension declaring the Action type defines type-specific processing data under config. Additional properties are permitted for forward compatibility.", + "additionalProperties": true, + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Identifier for this Action instance." + }, + "config": { + "type": "object", + "description": "Configuration defined by the extension that declares this Action type.", + "additionalProperties": true + } + }, + "required": [ + "id" + ] + } + } + }, + "messages": { + "type": "array", + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/message.json", + "title": "Message", + "type": "object", + "description": "Container for error, warning, or info messages.", + "oneOf": [ + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/message_error.json", + "title": "Message Error", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "error", + "description": "Message type discriminator." + }, + "code": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/error_code.json", + "title": "Error Code", + "description": "Error code identifying the type of error. Standard errors are defined in capability specifications (see examples) and have standardized semantics; freeform codes are permitted.", + "type": "string", + "examples": [ + "not_found", + "out_of_stock", + "item_unavailable", + "address_undeliverable", + "payment_failed", + "eligibility_invalid", + "identity_required", + "insufficient_scope" + ] + }, + "path": { + "type": "string", + "description": "RFC 9535 JSONPath to the component the message refers to (e.g., $.line_items[0])." + }, + "content_type": { + "type": "string", + "enum": [ + "plain", + "markdown" + ], + "default": "plain", + "description": "Content format, default = plain." + }, + "content": { + "type": "string", + "description": "Human-readable message." + }, + "severity": { + "type": "string", + "enum": [ + "recoverable", + "requires_buyer_input", + "requires_buyer_review", + "unrecoverable" + ], + "description": "Reflects the resource state and recommended action. 'recoverable': platform can resolve the condition in band, for example by modifying inputs or processing a related Action, and submit a new operation when needed. 'requires_buyer_input': merchant requires information their API doesn't support collecting programmatically (checkout incomplete). 'requires_buyer_review': buyer must authorize before order placement due to policy, regulatory, or entitlement rules. 'unrecoverable': no valid resource exists to act on, retry with new resource or inputs. Errors with 'requires_*' severity contribute to 'status: requires_escalation'." + } + }, + "required": [ + "type", + "code", + "content", + "severity" + ] + }, + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/message_warning.json", + "title": "Message Warning", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "warning", + "description": "Message type discriminator." + }, + "path": { + "type": "string", + "description": "RFC 9535 JSONPath to the component the message refers to (e.g., $.line_items[0])." + }, + "code": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/warning_code.json", + "title": "Warning Code", + "description": "Warning code identifying the type of warning. Standard codes are defined in capability specifications (see examples) and have standardized semantics; freeform codes are permitted.", + "type": "string", + "examples": [ + "final_sale", + "prop65", + "fulfillment_changed", + "payment_term_changed", + "age_restricted" + ] + }, + "content": { + "type": "string", + "description": "Human-readable warning message that MUST be displayed." + }, + "content_type": { + "type": "string", + "enum": [ + "plain", + "markdown" + ], + "default": "plain", + "description": "Content format, default = plain." + }, + "presentation": { + "type": "string", + "default": "notice", + "description": "Rendering contract for this warning. 'notice' (default): platform MUST display, MAY dismiss. 'disclosure': platform MUST display in proximity to the path-referenced component, MUST NOT hide or auto-dismiss. See specification for full contract." + }, + "image_url": { + "type": "string", + "format": "uri", + "description": "URL to a required visual element (e.g., warning symbol, energy class label)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Reference URL for more information (e.g., regulatory site, registry entry, policy page)." + } + }, + "required": [ + "type", + "code", + "content" + ] + }, + { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/message_info.json", + "title": "Message Info", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "info", + "description": "Message type discriminator." + }, + "path": { + "type": "string", + "description": "RFC 9535 JSONPath to the component the message refers to (e.g., $.line_items[0])." + }, + "code": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/info_code.json", + "title": "Info Code", + "description": "Info code identifying the type of informational message. Standard codes are defined in capability specifications (see examples) and have standardized semantics; freeform codes are permitted.", + "type": "string", + "examples": [ + "identity_optional", + "signal", + "free_shipping", + "not_found" + ] + }, + "content_type": { + "type": "string", + "enum": [ + "plain", + "markdown" + ], + "default": "plain", + "description": "Content format, default = plain." + }, + "content": { + "type": "string", + "description": "Human-readable message." + } + }, + "required": [ + "type", + "content" + ] + } + ] + }, + "description": "List of messages with error and info about the checkout session state." + }, + "links": { + "type": "array", + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/link.json", + "title": "Link", + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Type of link. Well-known values: `privacy_policy`, `terms_of_service`, `refund_policy`, `shipping_policy`, `faq`. Consumers SHOULD handle unknown values gracefully by displaying them using the `title` field or omitting the link." + }, + "url": { + "type": "string", + "description": "The actual URL pointing to the content to be displayed.", + "format": "uri" + }, + "title": { + "type": "string", + "description": "Optional display text for the link. When provided, use this instead of generating from type." + } + }, + "required": [ + "type", + "url" + ] + }, + "description": "Links to be displayed by the platform (Privacy Policy, TOS). Mandatory for legal compliance." + }, + "policies": { + "type": "array", + "items": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/policy.json", + "title": "Policy", + "description": "A durable business rule about the items in a response — return/refund terms, warranty, and the like — at the time of purchase. Every policy carries a `type` (an open reverse-DNS vocabulary) and a `description` so a platform can present it without understanding its type-specific fields; type-specific fields (gated by `type`) add structured context for platforms that model that type. Policies are reference data; the obligation to display a term to the buyer is carried by a `messages[]` warning whose `code` equals the policy `type` — see the Policies section of the specification.", + "type": "object", + "additionalProperties": true, + "properties": { + "type": { + "description": "Policy type discriminator. Open reverse-DNS vocabulary. Well-known values: `dev.ucp.shopping.policy.return` (return terms), `dev.ucp.shopping.policy.warranty` (warranty terms). Businesses MAY define custom types in their own domain (e.g., `com.example.policy.price_match`). Platforms MUST tolerate unknown values.", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/reverse_domain_name.json", + "title": "Reverse Domain Name", + "type": "string", + "pattern": "^[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9](?:[a-z0-9_-]*[a-z0-9_])?)+$", + "examples": [ + "dev.ucp.shopping.checkout", + "dev.ucp.common.identity_linking", + "com.example.loyalty_gold", + "com.example-shop.checkout", + "com.2example.cart", + "uk.co.example-shop.checkout", + "xn--p1ai.example.checkout" + ] + }, + "description": { + "description": "Human-readable policy summary in one or more formats (plain, markdown, html). Required on every policy so a platform can present it without understanding any type-specific fields. This is not the buyer-facing disclosure — display is compelled by a `messages[]` warning (see the Policies section).", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/description.json", + "title": "Description", + "type": "object", + "properties": { + "plain": { + "type": "string", + "description": "Plain text content." + }, + "html": { + "type": "string", + "description": "HTML-formatted content. Security: Platforms MUST sanitize before rendering—strip scripts, event handlers, and untrusted elements. Treat all rich text as untrusted input." + }, + "markdown": { + "type": "string", + "description": "Markdown-formatted content." + } + }, + "minProperties": 1 + }, + "applies_to": { + "type": "array", + "items": { + "type": "string" + }, + "description": "RFC 9535 JSONPath expressions identifying the nodes this policy applies to, relative to the embedding response root (e.g., `$.line_items[0]` in cart/checkout, `$.products[2]` in catalog). Each target covers the node it names and everything nested under it, so a target on a product also covers its variants. A singular query (RFC 9535 Section 2.3.5.1; name and index selectors only) names a single node; filters, wildcards, and slices match a set. When omitted, the policy applies to the entire response. When policies of the same `type` contest a node, the narrowest target wins and overrides the rest. See the Policies section for how specificity resolves." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Optional link to the full policy document." + } + }, + "required": [ + "type", + "description" + ] + }, + "description": "Policies (e.g., return/refund terms) that apply to the items in this checkout. `applies_to` targets are relative to the response root; when absent or empty, refer to the URLs in `links[]`." + }, + "expires_at": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 expiry timestamp. Default TTL is 6 hours from creation if not sent." + }, + "continue_url": { + "type": "string", + "format": "uri", + "description": "URL for checkout handoff and session recovery. MUST be provided when status is requires_escalation. See specification for format and availability requirements." + }, + "payment": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/common/types/payment.json", + "title": "Payment", + "description": "Payment configuration containing handlers.", + "type": "object", + "properties": { + "instruments": { + "type": "array", + "items": { + "title": "Selected Payment Instrument", + "description": "A payment instrument with selection state.", + "allOf": [ + { + "$ref": "#" + }, + { + "type": "object", + "properties": { + "selected": { + "type": "boolean", + "description": "Whether this instrument is selected by the user." + } + } + } + ] + }, + "description": "The payment instruments available for this payment. Each instrument is associated with a specific handler via the handler_id field. Handlers can extend the base payment_instrument schema to add handler-specific fields." + } + } + }, + "order": { + "description": "Details about an order created for this checkout session.", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ucp.dev/schemas/shopping/types/order_confirmation.json", + "title": "Order Confirmation", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique order identifier." + }, + "label": { + "type": "string", + "description": "Human-readable label for identifying the order. MUST only be provided by the business." + }, + "permalink_url": { + "type": "string", + "format": "uri", + "description": "Permalink to access the order on merchant site." + } + }, + "required": [ + "id", + "permalink_url" + ] + } + }, + "required": [ + "ucp", + "id", + "line_items", + "status", + "currency", + "totals", + "links" + ], + "$comment": "Generated from UCP v2026-08-25 by ucp-schema 1.4.1: bundled references and resolved visibility annotations; added this non-normative notice. See provenance.json and LICENSE-UCP." +} diff --git a/examples/agentic-commerce-accountability/released_ucp/schemas/provenance.json b/examples/agentic-commerce-accountability/released_ucp/schemas/provenance.json new file mode 100644 index 00000000..535dd741 --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/schemas/provenance.json @@ -0,0 +1,28 @@ +{ + "ucp_source": { + "repository": "https://github.com/Universal-Commerce-Protocol/ucp", + "release": "v2026-08-25", + "commit": "cd78fb38e819de77d9b527d110476eccb876f1bd", + "entrypoint": "source/schemas/shopping/checkout.json", + "entrypoint_sha256": "1a856fabbe86e031baab0c35745cb0b44481da29a2075ee1e73f720e6337204d", + "license": "Apache-2.0" + }, + "resolver": { + "repository": "https://github.com/Universal-Commerce-Protocol/ucp-schema", + "version": "1.4.1", + "commit": "6dd2a613d38828c97dca723bdb9ccfc79b2a7e80", + "install_command": "cargo install ucp-schema --version 1.4.1 --locked", + "published_crate_cargo_lock_sha256": "427f0bf6095e670ef5e9972f6a4385158d062b9d020c220d8a9c4ea5c04f6702" + }, + "resolution_commands_from_ucp_source_root": [ + "ucp-schema resolve source/schemas/shopping/checkout.json --response --op read --bundle --pretty --schema-local-base source/schemas --schema-remote-base https://ucp.dev/schemas --output checkout-response.json", + "ucp-schema resolve source/schemas/shopping/checkout.json --request --op complete --bundle --pretty --schema-local-base source/schemas --schema-remote-base https://ucp.dev/schemas --output checkout-complete-request.json" + ], + "postprocessing": "jq adds a non-normative $comment identifying the transformation; no validation keyword is manually changed. scripts/regenerate_schemas.sh is the complete regeneration procedure.", + "artifact_sha256": { + "checkout-response.json": "4768260556990c4ecd66275f9149d973434db5d7d177449a336de5bd5c19106d", + "checkout-complete-request.json": "d77977e20d5a637b5f277e7ee5ee8789cdfe7499a533cef58134fab099d52c7b", + "LICENSE-UCP": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30" + }, + "scope": "Base checkout response/read and request/complete shapes only. No runtime network lookup, arbitrary capability composition, handler-specific validation, or payment processing. The controller separately restricts the supported example profile." +} diff --git a/examples/agentic-commerce-accountability/released_ucp/scripts/regenerate_schemas.sh b/examples/agentic-commerce-accountability/released_ucp/scripts/regenerate_schemas.sh new file mode 100755 index 00000000..18f8b40a --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/scripts/regenerate_schemas.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# Mechanical generation only: do not hand-edit the vendored JSON Schemas. +set -eu + +if [ "$#" -ne 1 ]; then + echo "Usage: UCP_SCHEMA=/path/to/ucp-schema $0 /path/to/pinned-ucp-checkout" >&2 + exit 2 +fi + +ucp_source=$(cd "$1" && pwd) +output_dir=$(CDPATH= cd -- "$(dirname -- "$0")/../schemas" && pwd) +resolver=${UCP_SCHEMA:-ucp-schema} +expected_revision=cd78fb38e819de77d9b527d110476eccb876f1bd +if [ "$(git -C "$ucp_source" rev-parse HEAD)" != "$expected_revision" ]; then + echo "UCP checkout must be the exact v2026-08-25 source revision." >&2 + exit 1 +fi +if [ -n "$(git -C "$ucp_source" status --porcelain --untracked-files=normal)" ]; then + echo "UCP source checkout must be clean." >&2 + exit 1 +fi +if [ "$("$resolver" --version)" != "ucp-schema 1.4.1" ]; then + echo "The exact released ucp-schema 1.4.1 resolver is required." >&2 + exit 1 +fi + +cd "$ucp_source" +"$resolver" resolve source/schemas/shopping/checkout.json \ + --response --op read --bundle --pretty \ + --schema-local-base source/schemas --schema-remote-base https://ucp.dev/schemas \ + --output "$output_dir/checkout-response.json" +"$resolver" resolve source/schemas/shopping/checkout.json \ + --request --op complete --bundle --pretty \ + --schema-local-base source/schemas --schema-remote-base https://ucp.dev/schemas \ + --output "$output_dir/checkout-complete-request.json" + +# Add a notice to each transformed source without changing validation keywords. +notice='Generated from UCP v2026-08-25 by ucp-schema 1.4.1: bundled references and resolved visibility annotations; added this non-normative notice. See provenance.json and LICENSE-UCP.' +for filename in checkout-response.json checkout-complete-request.json; do + jq --arg notice "$notice" '. + {"$comment": $notice}' \ + "$output_dir/$filename" > "$output_dir/$filename.generated" + mv "$output_dir/$filename.generated" "$output_dir/$filename" +done + +# Retain the upstream Apache license alongside the transformed sources. +cp LICENSE "$output_dir/LICENSE-UCP" +echo "Generated schemas. Verify hashes against schemas/provenance.json." diff --git a/examples/agentic-commerce-accountability/released_ucp/scripts/smoke.sh b/examples/agentic-commerce-accountability/released_ucp/scripts/smoke.sh new file mode 100755 index 00000000..bf3b361a --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/scripts/smoke.sh @@ -0,0 +1,25 @@ +#!/bin/sh +# Dependencies are installed during image build / explicit host uv sync. +# No dependency resolution or downloads occur in this verification command. +set -eu + +cd -- "$(dirname -- "$0")/.." +smoke_scratch=$(mktemp -d) +trap 'rm -rf "$smoke_scratch"' EXIT HUP INT TERM +export COVERAGE_FILE="$smoke_scratch/.coverage" +export RUFF_CACHE_DIR="$smoke_scratch/ruff" +export UV_CACHE_DIR="$smoke_scratch/uv" +export PYTHONDONTWRITEBYTECODE=1 + +run() { + uv run --frozen --offline --no-sync "$@" +} + +run python -m ucp_commerce +run python -m coverage run --branch -m pytest -p no:cacheprovider +run python -m coverage report +run ruff check . +run ruff format --check . +run mypy --cache-dir "$smoke_scratch/mypy" +run bandit -q -r ucp_commerce +printf '%s\n' 'OFFLINE_SMOKE_PASSED' diff --git a/examples/agentic-commerce-accountability/released_ucp/tests/test_cli.py b/examples/agentic-commerce-accountability/released_ucp/tests/test_cli.py new file mode 100644 index 00000000..23ca0073 --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/tests/test_cli.py @@ -0,0 +1,182 @@ +"""Public CLI smoke and refusal behavior using real ephemeral signatures.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 + +from ucp_commerce.__main__ import main + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] + + +def assert_demo_summary(summary: dict) -> None: + assert summary["decision"] == "allow" + assert summary["spends"] == 1 + assert summary["effects"] == 1 + assert summary["invocation_number"] == 1 + assert summary["identical_retry_cached"] is True + assert summary["fresh_token_replay"] == "GRANT_SPENT" + + +def assert_sanitized_failure(result: int, captured, *forbidden: str) -> None: + assert result == 1 + output = captured.out + captured.err + assert output.strip() + assert "Traceback" not in output + for text in forbidden: + assert text not in output + + +@pytest.fixture +def exported(tmp_path, capsys) -> Path: + directory = tmp_path / "retained-demo" + assert main(["--output", str(directory)]) == 0 + capsys.readouterr() + return directory + + +def test_cli_default_demo_reports_one_effect_and_verified_retry_behavior(capsys): + assert main([]) == 0 + captured = capsys.readouterr() + assert not captured.err + assert_demo_summary(json.loads(captured.out)) + + +def test_cli_output_contains_only_bundle_and_public_trust(exported): + assert {path.name for path in exported.iterdir()} == {"bundle.json", "public-trust.json"} + trust = json.loads((exported / "public-trust.json").read_text()) + assert set(trust) == {"merchant", "platform", "authority", "receipt"} + for role, pem in trust.items(): + key = serialization.load_pem_public_key(pem.encode()) + if role in {"merchant", "platform"}: + assert isinstance(key, ec.EllipticCurvePublicKey) + assert isinstance(key.curve, ec.SECP256R1) + else: + assert isinstance(key, ed25519.Ed25519PublicKey) + for path in exported.iterdir(): + contents = path.read_text() + assert "PRIVATE KEY" not in contents + assert "BEGIN EC PRIVATE" not in contents + assert json.loads((exported / "bundle.json").read_text())["receipt"] + + +@pytest.mark.parametrize("existing_kind", ["directory", "file"]) +def test_cli_never_overwrites_an_existing_output_target(tmp_path, capsys, existing_kind): + target = tmp_path / "existing-output" + if existing_kind == "directory": + target.mkdir() + sentinel = target / "preserve.txt" + else: + sentinel = target + sentinel.write_text("existing user content") + result = main(["--output", str(target)]) + assert_sanitized_failure(result, capsys.readouterr()) + assert sentinel.read_text() == "existing user content" + if target.is_dir(): + assert {path.name for path in target.iterdir()} == {"preserve.txt"} + + +def test_cli_export_can_be_audited_in_a_fresh_process(exported): + result = subprocess.run( + [ + sys.executable, + "-m", + "ucp_commerce", + "audit", + str(exported / "bundle.json"), + "--trust", + str(exported / "public-trust.json"), + ], + cwd=EXAMPLE_ROOT, + capture_output=True, + text=True, + check=False, + timeout=20, + ) + assert result.returncode == 0, result.stderr + assert not result.stderr + decision = json.loads(result.stdout) + assert decision["decision"] == "allow" + assert decision["invocation_number"] == 1 + + +def test_cli_audit_refuses_a_different_configured_authority_key(exported, capsys): + trust_path = exported / "public-trust.json" + trust = json.loads(trust_path.read_text()) + trust["authority"] = ( + ed25519.Ed25519PrivateKey.generate() + .public_key() + .public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo) + .decode() + ) + trust_path.write_text(json.dumps(trust)) + result = main(["audit", str(exported / "bundle.json"), "--trust", str(trust_path)]) + assert_sanitized_failure(result, capsys.readouterr(), trust["authority"]) + + +def test_cli_audit_refuses_tampered_signed_evidence(exported, capsys): + bundle_path = exported / "bundle.json" + bundle = json.loads(bundle_path.read_text()) + bundle["receipt"] = "tampered-private-business-marker" + bundle_path.write_text(json.dumps(bundle)) + result = main(["audit", str(bundle_path), "--trust", str(exported / "public-trust.json")]) + assert_sanitized_failure(result, capsys.readouterr(), bundle["receipt"]) + + +@pytest.mark.parametrize("mutation", ["unknown_field", "missing_field", "wrong_type"]) +def test_cli_audit_requires_closed_strict_public_trust(exported, capsys, mutation): + trust_path = exported / "public-trust.json" + trust = json.loads(trust_path.read_text()) + marker = "private-input-do-not-echo" + if mutation == "unknown_field": + trust["unknown"] = marker + elif mutation == "missing_field": + del trust["receipt"] + else: + trust["authority"] = {"unexpected": marker} + trust_path.write_text(json.dumps(trust)) + result = main(["audit", str(exported / "bundle.json"), "--trust", str(trust_path)]) + assert_sanitized_failure(result, capsys.readouterr(), marker) + + +@pytest.mark.parametrize("mixed_with_public", [False, True]) +def test_cli_audit_refuses_private_key_material_in_public_trust( + exported, capsys, mixed_with_public +): + trust_path = exported / "public-trust.json" + trust = json.loads(trust_path.read_text()) + # Disposable test-only key: never a checked-in credential or service key. + private_pem = ( + ed25519.Ed25519PrivateKey.generate() + .private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + .decode() + ) + trust["authority"] = (trust["authority"] if mixed_with_public else "") + private_pem + trust_path.write_text(json.dumps(trust)) + result = main(["audit", str(exported / "bundle.json"), "--trust", str(trust_path)]) + assert_sanitized_failure(result, capsys.readouterr(), private_pem, "PRIVATE KEY") + + +def test_cli_audit_does_not_echo_malformed_bundle_input(exported, capsys): + bundle_path = exported / "bundle.json" + marker = "private-business-data-not-json" + bundle_path.write_text(marker) + result = main(["audit", str(bundle_path), "--trust", str(exported / "public-trust.json")]) + assert_sanitized_failure(result, capsys.readouterr(), marker) + + +def test_cli_audit_missing_file_is_a_sanitized_failure(exported, capsys): + missing = exported / "private-customer-location.json" + result = main(["audit", str(missing), "--trust", str(exported / "public-trust.json")]) + assert_sanitized_failure(result, capsys.readouterr(), str(missing)) diff --git a/examples/agentic-commerce-accountability/released_ucp/tests/test_evidence.py b/examples/agentic-commerce-accountability/released_ucp/tests/test_evidence.py new file mode 100644 index 00000000..9dabfd0e --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/tests/test_evidence.py @@ -0,0 +1,406 @@ +"""Real released-library signing/verification, without crypto test doubles.""" + +from __future__ import annotations + +import json +from datetime import UTC, datetime, timedelta + +import pytest +import rfc8785 +from agentrust_trace import jwk_thumbprint, key_to_jwk, sign_record, validate_json, verify_record +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from joserfc import jws +from joserfc.jwk import OKPKey +from pydantic import ValidationError + +from ucp_commerce.evidence import ( + DECISION_TYPE, + GRANT_ALGORITHM, + GRANT_TYPE, + MAX_SAFE_INTEGER, + MAX_UNIX_TIME, + AuthorityGrant, + EvidenceError, + create_trace, + sign_decision, + sign_grant, + verify_decision, + verify_grant, + verify_trace, +) + + +@pytest.fixture +def grant() -> AuthorityGrant: + return AuthorityGrant( + grant_id="grant-01", + merchant_origin="http://127.0.0.1:8123", + merchant_key_sha256="a" * 64, + platform_key_sha256="b" * 64, + currency="USD", + max_amount_minor=20_000, + operation="checkout.complete", + expires_at=1_800_000_000, + ) + + +@pytest.fixture +def signing_key() -> Ed25519PrivateKey: + return Ed25519PrivateKey.generate() + + +def signed_bytes(payload: bytes, key: Ed25519PrivateKey, **headers: str) -> str: + protected = {"alg": GRANT_ALGORITHM, "kid": "authority-1", "typ": GRANT_TYPE} + protected.update(headers) + return jws.serialize_compact( + protected, payload, OKPKey.import_key(key), algorithms=[GRANT_ALGORITHM] + ) + + +def test_grant_round_trip_is_canonical_and_protected(grant, signing_key): + token = sign_grant(grant, signing_key, "authority-1") + public_key = signing_key.public_key() + assert verify_grant(token, public_key, "authority-1") == grant + decoded = jws.deserialize_compact( + token, OKPKey.import_key(public_key), algorithms=[GRANT_ALGORITHM] + ) + assert decoded.protected == { + "alg": GRANT_ALGORITHM, + "kid": "authority-1", + "typ": GRANT_TYPE, + } + assert decoded.payload == rfc8785.dumps(grant.model_dump()) + + +def test_grant_requires_configured_key(grant, signing_key): + token = sign_grant(grant, signing_key, "authority-1") + other_key = Ed25519PrivateKey.generate().public_key() + with pytest.raises(EvidenceError, match="^invalid authority grant$"): + verify_grant(token, other_key, "authority-1") + + +@pytest.mark.parametrize("header,value", [("kid", "other"), ("typ", "other"), ("cty", "json")]) +def test_grant_refuses_wrong_or_additional_protected_header(grant, signing_key, header, value): + token = signed_bytes(rfc8785.dumps(grant.model_dump()), signing_key, **{header: value}) + with pytest.raises(EvidenceError, match="^invalid authority grant$"): + verify_grant(token, signing_key.public_key(), "authority-1") + + +@pytest.mark.parametrize( + "field,value", + [ + ("max_amount_minor", "20000"), + ("max_amount_minor", True), + ("max_amount_minor", 0.5), + ("max_amount_minor", 0), + ("max_amount_minor", -1), + ("expires_at", "1800000000"), + ("expires_at", True), + ("expires_at", 0.5), + ("expires_at", 0), + ("expires_at", MAX_UNIX_TIME + 1), + ("merchant_key_sha256", "a" * 63), + ("merchant_key_sha256", "A" * 64), + ("platform_key_sha256", "not-a-digest"), + ("operation", "checkout.cancel"), + ("currency", "usd"), + ("grant_id", ""), + ("merchant_origin", "https://merchant.example/path"), + ("merchant_origin", "https://user@merchant.example"), + ("merchant_origin", "https://merchant.example?redirect=other"), + ("merchant_origin", "https://merchant.example#fragment"), + ("merchant_origin", "https://merchant.example:99999"), + ("merchant_origin", "https://merchant.example\n"), + ("merchant_origin", "file://merchant.example"), + ], +) +def test_correctly_signed_invalid_claim_is_refused(grant, signing_key, field, value): + payload = grant.model_dump() + payload[field] = value + token = signed_bytes(rfc8785.dumps(payload), signing_key) + with pytest.raises(EvidenceError, match="^invalid authority grant$"): + verify_grant(token, signing_key.public_key(), "authority-1") + + +def test_amount_must_fit_canonical_integer_domain(grant): + payload = grant.model_dump() + payload["max_amount_minor"] = MAX_SAFE_INTEGER + 1 + with pytest.raises(ValidationError): + AuthorityGrant.model_validate(payload) + + +@pytest.mark.parametrize("mutation", ["unknown", "missing", "duplicate", "not_canonical", "array"]) +def test_grant_rejects_ambiguous_or_open_schema_payloads(grant, signing_key, mutation): + payload = grant.model_dump() + if mutation == "unknown": + payload["extra"] = "not a grant field" + raw = rfc8785.dumps(payload) + elif mutation == "missing": + del payload["expires_at"] + raw = rfc8785.dumps(payload) + elif mutation == "duplicate": + raw = b'{"grant_id":"earlier",' + rfc8785.dumps(payload)[1:] + elif mutation == "array": + raw = rfc8785.dumps([payload]) + else: + raw = json.dumps(payload, indent=2).encode() + token = signed_bytes(raw, signing_key) + with pytest.raises(EvidenceError, match="^invalid authority grant$"): + verify_grant(token, signing_key.public_key(), "authority-1") + + +def test_grant_payload_substitution_fails_signature(grant, signing_key): + token = sign_grant(grant, signing_key, "authority-1") + substituted = grant.model_copy(update={"max_amount_minor": 50_000}) + other_token = sign_grant(substituted, signing_key, "authority-1") + header, _, signature = token.split(".") + _, changed_payload, _ = other_token.split(".") + with pytest.raises(EvidenceError, match="^invalid authority grant$"): + verify_grant( + f"{header}.{changed_payload}.{signature}", signing_key.public_key(), "authority-1" + ) + + +@pytest.mark.parametrize("token", ["", "not-jws", "x" * 8193, 123]) +def test_invalid_token_is_a_generic_refusal(signing_key, token): + with pytest.raises(EvidenceError, match="^invalid authority grant$"): + verify_grant(token, signing_key.public_key(), "authority-1") + + +def test_signing_revalidates_model_copy_bypass(grant, signing_key): + invalid = grant.model_copy(update={"expires_at": "not-an-integer"}) + with pytest.raises(ValidationError): + sign_grant(invalid, signing_key, "authority-1") + + +def test_cryptographic_grant_verification_does_not_decide_expiry(grant, signing_key): + # Controller/auditor tests own the real clock-and-business authorization gate. + old_grant = grant.model_copy(update={"expires_at": 1}) + token = sign_grant(old_grant, signing_key, "authority-1") + assert verify_grant(token, signing_key.public_key(), "authority-1").expires_at == 1 + + +def test_grant_rejects_private_key_as_verification_trust(grant, signing_key): + token = sign_grant(grant, signing_key, "authority-1") + with pytest.raises(EvidenceError, match="trusted Ed25519 public key"): + verify_grant(token, signing_key, "authority-1") + + +@pytest.mark.parametrize("kid", ["", "k" * 129, 123]) +def test_grant_requires_exact_configured_key_identifier(grant, signing_key, kid): + with pytest.raises(EvidenceError, match="configured grant key identifier"): + sign_grant(grant, signing_key, kid) + + +@pytest.fixture +def evaluation_time() -> datetime: + return datetime(2026, 9, 7, 12, 0, 0, tzinfo=UTC) + + +@pytest.fixture +def transcript() -> dict: + return { + "policy": {"currency": "USD", "operation": "checkout.complete"}, + "request": {"operation": "checkout.complete"}, + "response": {"status": "completed"}, + } + + +def test_trace_uses_released_schema_signature_and_explicit_local_revocation( + transcript, signing_key, evaluation_time +): + record = create_trace(transcript, signing_key, evaluation_time) + validate_json(record) + result = verify_record( + record, + signing_key.public_key(), + revocation=frozenset(), + now=int(evaluation_time.timestamp()), + ) + assert result.revocation.outcome == "verified" + assert result.revocation.evidence == {"source": "store"} + verify_trace(record, signing_key.public_key(), evaluation_time, transcript) + assert record["runtime"]["platform"] == "software-only" + assert record["appraisal"]["status"] == "none" + assert record["policy"]["enforcement_mode"] == "enforce" + assert record["build_provenance"]["slsa_level"] == 0 + assert "transparency" not in record + assert "tool_transcript" not in record + assert "references" not in record + assert "d" not in record["cnf"]["jwk"] + + +def test_trace_refuses_wrong_external_trust(transcript, signing_key, evaluation_time): + record = create_trace(transcript, signing_key, evaluation_time) + other_key = Ed25519PrivateKey.generate().public_key() + with pytest.raises(EvidenceError, match="^invalid TRACE observation record$"): + verify_trace(record, other_key, evaluation_time, transcript) + + +def test_trace_refuses_modified_policy(transcript, signing_key, evaluation_time): + record = create_trace(transcript, signing_key, evaluation_time) + modified = {**transcript, "policy": {"currency": "EUR"}} + with pytest.raises(EvidenceError, match="^invalid TRACE observation record$"): + verify_trace(record, signing_key.public_key(), evaluation_time, modified) + + +def test_trace_refuses_modified_signed_record(transcript, signing_key, evaluation_time): + record = create_trace(transcript, signing_key, evaluation_time) + record["iat"] += 1 + with pytest.raises(EvidenceError, match="^invalid TRACE observation record$"): + verify_trace( + record, signing_key.public_key(), evaluation_time + timedelta(seconds=1), transcript + ) + + +@pytest.mark.parametrize("offset", [-1, 301]) +def test_trace_time_bounds_are_injected(transcript, signing_key, evaluation_time, offset): + record = create_trace(transcript, signing_key, evaluation_time) + with pytest.raises(EvidenceError, match="^invalid TRACE observation record$"): + verify_trace( + record, + signing_key.public_key(), + evaluation_time + timedelta(seconds=offset), + transcript, + ) + + +def test_trace_refuses_revoked_local_key(transcript, signing_key, evaluation_time): + record = create_trace(transcript, signing_key, evaluation_time) + revoked = frozenset({jwk_thumbprint(key_to_jwk(signing_key))}) + with pytest.raises(EvidenceError, match="^invalid TRACE observation record$"): + verify_trace( + record, + signing_key.public_key(), + evaluation_time, + transcript, + revoked_key_ids=revoked, + ) + + +@pytest.mark.parametrize( + "field,value", + [ + ("runtime", {"platform": "tpm2", "measurement": "sha256:" + "a" * 64}), + ("appraisal", {"status": "affirming", "verifier": "urn:example:appraiser"}), + ("origin", {"kind": "log-import", "producer": "other"}), + ("model", {"provider": "claimed-provider", "model_id": "claimed-model"}), + ("subject", "did:example:other-controller"), + ("policy", {"bundle_hash": "sha256:" + "a" * 64, "enforcement_mode": "enforce"}), + ("tool_transcript", {"hash": "sha256:" + "a" * 64}), + ("build_provenance", {"slsa_level": 3, "digest": "sha256:" + "a" * 64}), + ("iat", "1788782400"), + ("iat", True), + ("unknown", "not in the released schema"), + ], +) +def test_correctly_signed_trace_must_keep_local_assurance_ceiling( + transcript, signing_key, evaluation_time, field, value +): + record = create_trace(transcript, signing_key, evaluation_time) + record[field] = value + resigned = sign_record(record, signing_key) + with pytest.raises(EvidenceError, match="^invalid TRACE observation record$"): + verify_trace(resigned, signing_key.public_key(), evaluation_time, transcript) + + +def test_trace_refuses_naive_evaluation_time(transcript, signing_key, evaluation_time): + with pytest.raises(EvidenceError, match="aware datetime"): + create_trace(transcript, signing_key, evaluation_time.replace(tzinfo=None)) + + +def test_trace_signature_is_not_purchase_authorization(transcript, signing_key, evaluation_time): + # This helper authenticates the controller's observation only. The main + # controller/auditor must reject missing grants, receipts, and business links. + arbitrary_observation = { + "policy": transcript["policy"], + "claim": "a signature cannot make this purchase authorized", + } + record = create_trace(arbitrary_observation, signing_key, evaluation_time) + verify_trace(record, signing_key.public_key(), evaluation_time, arbitrary_observation) + + +def test_nonpolicy_http_data_needs_the_outer_decision_binding( + transcript, signing_key, evaluation_time +): + record = create_trace(transcript, signing_key, evaluation_time) + changed_http_data = {**transcript, "response": {"status": "changed"}} + # TRACE does not pretend UCP HTTP is MCP/A2A. Auditor tests exercise the outer + # signed decision's business hashes plus runtime_record_sha256 instead. + verify_trace(record, signing_key.public_key(), evaluation_time, changed_http_data) + + +def test_decision_jws_authenticates_exact_values_without_coercing_them(signing_key): + payload = {"decision": "allow", "value": "123", "flag": "false"} + token = sign_decision(payload, signing_key, "decision-key") + assert verify_decision(token, signing_key.public_key(), "decision-key") == payload + # Signature verification returns exact types. The separate Decision model + # rejects wrong types/unknown claims before the business auditor uses them. + + +def test_decision_cannot_be_used_as_a_grant(grant, signing_key): + token = sign_decision(grant.model_dump(), signing_key, "authority-1") + with pytest.raises(EvidenceError, match="^invalid authority grant$"): + verify_grant(token, signing_key.public_key(), "authority-1") + + +def test_grant_cannot_be_used_as_a_decision(grant, signing_key): + token = sign_grant(grant, signing_key, "authority-1") + with pytest.raises(EvidenceError, match="^invalid decision receipt$"): + verify_decision(token, signing_key.public_key(), "authority-1") + + +def test_decision_requires_external_trust(signing_key): + token = sign_decision({"decision": "allow"}, signing_key, "decision-key") + with pytest.raises(EvidenceError, match="^invalid decision receipt$"): + verify_decision(token, Ed25519PrivateKey.generate().public_key(), "decision-key") + + +@pytest.mark.parametrize("raw", [b"[]", b'{"x": 1}', b'{"x":1,"x":2}']) +def test_decision_rejects_noncanonical_or_nonobject_payload(signing_key, raw): + token = signed_bytes(raw, signing_key, typ=DECISION_TYPE) + with pytest.raises(EvidenceError, match="^invalid decision receipt$"): + verify_decision(token, signing_key.public_key(), "authority-1") + + +@pytest.mark.parametrize("token", ["", "x" * 65_537, None]) +def test_decision_refuses_malformed_token(signing_key, token): + with pytest.raises(EvidenceError, match="^invalid decision receipt$"): + verify_decision(token, signing_key.public_key(), "decision-key") + + +def test_signers_and_verifiers_require_their_respective_key_types(grant, signing_key): + with pytest.raises(EvidenceError, match="Ed25519 private key"): + sign_grant(grant, signing_key.public_key(), "authority-1") + with pytest.raises(EvidenceError, match="Ed25519 private key"): + sign_decision({}, signing_key.public_key(), "decision-key") + with pytest.raises(EvidenceError, match="Ed25519 public key"): + verify_decision("", signing_key, "decision-key") + + +@pytest.mark.parametrize("policy", [None, {}, [], "policy"]) +def test_trace_requires_nonempty_policy_object(signing_key, evaluation_time, policy): + with pytest.raises(EvidenceError, match="local application policy"): + create_trace({"policy": policy}, signing_key, evaluation_time) + + +def test_trace_refuses_invalid_local_parameters(transcript, signing_key, evaluation_time): + with pytest.raises(EvidenceError, match="Ed25519 private key"): + create_trace(transcript, signing_key.public_key(), evaluation_time) + with pytest.raises(EvidenceError, match="outside the example profile"): + create_trace(transcript, signing_key, datetime(2020, 1, 1, tzinfo=UTC)) + record = create_trace(transcript, signing_key, evaluation_time) + with pytest.raises(EvidenceError, match="trusted Ed25519 public key"): + verify_trace(record, signing_key, evaluation_time, transcript) + with pytest.raises(EvidenceError, match="^invalid TRACE observation record$"): + verify_trace([], signing_key.public_key(), evaluation_time, transcript) + with pytest.raises(EvidenceError, match="^invalid TRACE observation record$"): + verify_trace(record, signing_key.public_key(), evaluation_time, {}) + with pytest.raises(EvidenceError, match="^invalid TRACE observation record$"): + verify_trace( + record, + signing_key.public_key(), + evaluation_time, + transcript, + revoked_key_ids=frozenset({123}), + ) diff --git a/examples/agentic-commerce-accountability/released_ucp/tests/test_flow.py b/examples/agentic-commerce-accountability/released_ucp/tests/test_flow.py new file mode 100644 index 00000000..e3da2150 --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/tests/test_flow.py @@ -0,0 +1,711 @@ +"""Functional merchant/audit regressions: real signatures, local SQLite effects. + +No payment provider, network merchant, hardware attestation, or crypto test +double is used. Instrumentation observes actual private-handler entry, not just +committed counters, so a rollback cannot masquerade as pre-handler denial. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import sqlite3 +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, replace +from datetime import UTC, datetime, timedelta +from pathlib import Path +from threading import Barrier, Event, Lock +from types import SimpleNamespace +from typing import Any + +import pytest +from http_message_signatures import HTTPMessageSigner, HTTPSignatureKeyResolver, algorithms + +from ucp_commerce import flow +from ucp_commerce.evidence import ( + create_trace, + sign_decision, + sign_grant, + verify_decision, + verify_grant, + verify_trace, +) +from ucp_commerce.flow import ( + GRANT_KID, + MERCHANT, + MERCHANT_KID, + PLATFORM_KID, + PLATFORM_PROFILE, + POLICY, + RECEIPT_KID, + AuditError, + Bundle, + Denied, + Keys, + Merchant, + audit, + completion_request, + make_grant, +) +from ucp_commerce.schema import sample_checkout, sample_complete_request +from ucp_commerce.wire import ( + SignedMessage, + sign_request, + sign_response, + verify_request, + verify_response, +) + +NOW = datetime(2026, 9, 7, 12, tzinfo=UTC) +CHECKOUT = "checkout-001" + + +@dataclass +class Harness: + merchant: Merchant + keys: Keys + quote: SignedMessage + request: SignedMessage + grant: str + calls: list[tuple[str, int]] + + def complete(self, **changes: Any) -> Bundle: + arguments = {"token": self.grant, "checkout": self.quote, "request": self.request} + arguments.update(changes) + return self.merchant.complete(**arguments) + + def assert_denial(self, reason: str, **changes: Any) -> None: + with pytest.raises(Denied) as error: + self.complete(**changes) + assert error.value.reason == reason + assert str(error.value) == reason + assert self.calls == [] + assert self.merchant.counts() == (0, 0) + + +@pytest.fixture +def harness(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Harness: + keys = Keys.generate() + merchant = Merchant(tmp_path / "merchant.sqlite", keys, clock=lambda: NOW) + merchant.seed_checkout(sample_checkout()) + calls: list[tuple[str, int]] = [] + call_lock = Lock() + actual = merchant._complete_checkout + + def observed(connection: sqlite3.Connection, checkout: dict, invocation: int) -> dict: + with call_lock: + calls.append((checkout["id"], invocation)) + return actual(connection, checkout, invocation) + + monkeypatch.setattr(merchant, "_complete_checkout", observed) + return Harness( + merchant, + keys, + merchant.quote(CHECKOUT), + completion_request(keys, NOW, CHECKOUT), + make_grant(keys, NOW, grant_id="grant-one"), + calls, + ) + + +def changed_grant(harness: Harness, **changes: Any) -> str: + original = verify_grant(harness.grant, harness.keys.authority.public_key(), GRANT_KID) + return sign_grant(original.model_copy(update=changes), harness.keys.authority, GRANT_KID) + + +def signed_quote(harness: Harness, body: dict, *, status: int = 200) -> SignedMessage: + return sign_response(harness.request, status, body, harness.keys.merchant, MERCHANT_KID, NOW) + + +def changed_request( + harness: Harness, *, body: dict | None = None, url: str | None = None, method: str = "POST" +) -> SignedMessage: + return sign_request( + method, + url or harness.request.url, + sample_complete_request() if body is None else body, + harness.keys.platform, + PLATFORM_KID, + harness.request.header("idempotency-key"), + NOW, + ucp_agent=PLATFORM_PROFILE, + ) + + +def differently_serialized_request(harness: Harness) -> SignedMessage: + """Independent released-library signing of equivalent but different raw JSON.""" + + class Resolver(HTTPSignatureKeyResolver): + def resolve_private_key(self, key_id: str) -> Any: + assert key_id == PLATFORM_KID + return harness.keys.platform + + body = json.dumps(sample_complete_request(), indent=2).encode() + transport = SimpleNamespace( + method="POST", + url=harness.request.url, + headers={ + "content-type": "application/json", + "content-digest": "sha-256=:" + + base64.b64encode(hashlib.sha256(body).digest()).decode() + + ":", + "idempotency-key": harness.request.header("idempotency-key"), + "ucp-agent": f'profile="{PLATFORM_PROFILE}"', + }, + ) + HTTPMessageSigner( + signature_algorithm=algorithms.ECDSA_P256_SHA256, key_resolver=Resolver() + ).sign( + transport, + key_id=PLATFORM_KID, + created=NOW, + expires=NOW + timedelta(minutes=5), + include_alg=False, + covered_component_ids=( + "@method", + "@authority", + "@path", + "content-type", + "content-digest", + "idempotency-key", + "ucp-agent", + ), + ) + return SignedMessage( + method="POST", + url=harness.request.url, + status=None, + headers=tuple((name.lower(), value) for name, value in transport.headers.items()), + body_b64=base64.b64encode(body).decode(), + ) + + +def test_allow_round_trip_and_repeatable_read_only_audit(harness: Harness) -> None: + bundle = harness.complete() + restored = Bundle.model_validate_json(bundle.model_dump_json()) + first = audit(restored, harness.keys.trust(), NOW) + assert first == audit(restored, harness.keys.trust(), NOW) + assert first.decision == "allow" + assert first.invocation_number == 1 + assert first.grant_id == "grant-one" + assert first.checkout_id == CHECKOUT + assert ( + verify_response(bundle.response, harness.keys.trust().merchant, MERCHANT_KID, NOW)["status"] + == "completed" + ) + assert harness.calls == [(CHECKOUT, 1)] + assert harness.merchant.counts() == (1, 1) + with sqlite3.connect(harness.merchant.path) as connection: + assert connection.execute("SELECT count(*) FROM retries").fetchone()[0] == 1 + + +def test_correctly_signed_overspend_is_policy_denial(harness: Harness) -> None: + token = changed_grant(harness, max_amount_minor=12_499) + assert verify_grant(token, harness.keys.trust().authority, GRANT_KID).max_amount_minor == 12_499 + assert ( + verify_response(harness.quote, harness.keys.trust().merchant, MERCHANT_KID, NOW)["totals"][ + -1 + ]["amount"] + == 12_500 + ) + harness.assert_denial("OVERSPEND", token=token) + + +@pytest.mark.parametrize( + "updates,reason", + [ + ({"merchant_origin": "https://other.example"}, "MERCHANT_AUTHORITY"), + ({"merchant_key_sha256": "0" * 64}, "MERCHANT_AUTHORITY"), + ({"platform_key_sha256": "0" * 64}, "PLATFORM_AUTHORITY"), + ({"currency": "GBP"}, "CURRENCY"), + ({"expires_at": int(NOW.timestamp())}, "GRANT_EXPIRED"), + ], +) +def test_signed_authority_constraints_are_enforced( + harness: Harness, updates: dict, reason: str +) -> None: + harness.assert_denial(reason, token=changed_grant(harness, **updates)) + + +@pytest.mark.parametrize("role", ["merchant", "platform", "authority"]) +def test_untrusted_keys_cannot_self_authorize(harness: Harness, role: str) -> None: + other = Keys.generate() + if role == "merchant": + quote = sign_response( + harness.request, 200, sample_checkout(), other.merchant, MERCHANT_KID, NOW + ) + harness.assert_denial("AUTHENTICATION_FAILED", checkout=quote) + elif role == "platform": + request = completion_request(other, NOW, CHECKOUT) + harness.assert_denial("AUTHENTICATION_FAILED", request=request) + else: + original = verify_grant(harness.grant, harness.keys.trust().authority, GRANT_KID) + token = sign_grant(original, other.authority, GRANT_KID) + harness.assert_denial("AUTHENTICATION_FAILED", token=token) + + +@pytest.mark.parametrize( + "updates", + [ + {"url": "https://other.example/checkout-sessions/checkout-001/complete"}, + {"url": f"{MERCHANT}/checkout-sessions/different/complete"}, + {"method": "PUT"}, + ], +) +def test_authenticated_target_substitution_is_denied(harness: Harness, updates: dict) -> None: + request = changed_request(harness, **updates) + assert ( + verify_request(request, harness.keys.trust().platform, PLATFORM_KID, NOW) + == sample_complete_request() + ) + harness.assert_denial("REQUEST_TARGET", request=request) + + +@pytest.mark.parametrize("profile", [None, "https://other-platform.example/.well-known/ucp"]) +def test_missing_or_wrong_authenticated_platform_profile_is_denied( + harness: Harness, profile: str | None +) -> None: + request = sign_request( + "POST", + harness.request.url, + sample_complete_request(), + harness.keys.platform, + PLATFORM_KID, + harness.request.header("idempotency-key"), + NOW, + ucp_agent=profile, + ) + assert ( + verify_request(request, harness.keys.trust().platform, PLATFORM_KID, NOW) + == sample_complete_request() + ) + harness.assert_denial("PLATFORM_PROFILE", request=request) + + +def test_response_transport_annotations_are_not_used_as_authority(harness: Harness) -> None: + annotated = harness.quote.model_copy( + update={"url": "https://other.example/unrelated", "method": "DELETE"} + ) + bundle = harness.complete(checkout=annotated) + assert audit(bundle, harness.keys.trust(), NOW).checkout_id == CHECKOUT + assert harness.calls == [(CHECKOUT, 1)] + # The merchant key, signed checkout ID, and authenticated request target are + # authoritative. Default UCP response method/URL annotations are not signed. + + +def test_signed_schema_invalid_completion_denied_before_handler(harness: Harness) -> None: + request = changed_request(harness, body={"payment": {"instruments": "not-an-array"}}) + assert ( + verify_request(request, harness.keys.trust().platform, PLATFORM_KID, NOW)["payment"][ + "instruments" + ] + == "not-an-array" + ) + harness.assert_denial("SCHEMA_INVALID", request=request) + + +def test_signed_unsupported_checkout_profile_denied(harness: Harness) -> None: + terms = sample_checkout() + terms["line_items"][0]["item"]["title"] = "Different fictional item" + harness.assert_denial("UNSUPPORTED_CHECKOUT", checkout=signed_quote(harness, terms)) + + +def test_signed_wrong_quote_status_denied(harness: Harness) -> None: + harness.assert_denial( + "CHECKOUT_STATUS", checkout=signed_quote(harness, sample_checkout(), status=201) + ) + + +def test_signed_unknown_checkout_denied(harness: Harness) -> None: + quote = signed_quote(harness, sample_checkout("unknown-checkout")) + request = completion_request(harness.keys, NOW, "unknown-checkout") + harness.assert_denial("CHECKOUT_UNKNOWN", checkout=quote, request=request) + + +def test_stale_signed_quote_is_rejected_against_current_checkout(harness: Harness) -> None: + with sqlite3.connect(harness.merchant.path) as connection: + connection.execute( + "UPDATE checkouts SET body=? WHERE id=?", + (json.dumps(sample_checkout(amount_minor=15_000)), CHECKOUT), + ) + assert ( + verify_response(harness.quote, harness.keys.trust().merchant, MERCHANT_KID, NOW) + == sample_checkout() + ) + harness.assert_denial("STALE_CHECKOUT") + + +def test_identical_retry_returns_original_signed_bundle(harness: Harness) -> None: + first = harness.complete() + retry = harness.complete() + assert retry.model_dump_json() == first.model_dump_json() + assert audit(retry, harness.keys.trust(), NOW).invocation_number == 1 + assert harness.calls == [(CHECKOUT, 1)] + assert harness.merchant.counts() == (1, 1) + + +def test_idempotency_survives_merchant_restart( + harness: Harness, monkeypatch: pytest.MonkeyPatch +) -> None: + first = harness.complete() + reopened = Merchant(harness.merchant.path, harness.keys, clock=lambda: NOW) + unexpected_calls: list[bool] = [] + + def forbidden(*args: Any) -> None: + unexpected_calls.append(True) + raise AssertionError("cached retry must not invoke the handler") + + monkeypatch.setattr(reopened, "_complete_checkout", forbidden) + retry = reopened.complete(harness.grant, harness.quote, harness.request) + assert retry.model_dump_json() == first.model_dump_json() + assert audit(retry, harness.keys.trust(), NOW).invocation_number == 1 + assert unexpected_calls == [] + assert harness.calls == [(CHECKOUT, 1)] + assert reopened.counts() == (1, 1) + + +def test_new_idempotency_key_does_not_redeem_spent_grant(harness: Harness) -> None: + harness.complete() + with pytest.raises(Denied) as error: + harness.complete(request=completion_request(harness.keys, NOW, CHECKOUT)) + assert error.value.reason == "GRANT_SPENT" + assert harness.calls == [(CHECKOUT, 1)] + assert harness.merchant.counts() == (1, 1) + + +def test_same_idempotency_key_with_changed_raw_payload_conflicts(harness: Harness) -> None: + harness.complete() + # A new authenticated but changed body must not return a previous success. + request = changed_request( + harness, body={"payment": {"instruments": []}, "example_changed": True} + ) + with pytest.raises(Denied) as error: + harness.complete(request=request) + assert error.value.reason == "IDEMPOTENCY_CONFLICT" + assert harness.calls == [(CHECKOUT, 1)] + assert harness.merchant.counts() == (1, 1) + + +def test_equivalent_json_with_same_idempotency_key_still_conflicts(harness: Harness) -> None: + harness.complete() + changed = differently_serialized_request(harness) + assert ( + verify_request(changed, harness.keys.trust().platform, PLATFORM_KID, NOW) + == sample_complete_request() + ) + assert changed.body_b64 != harness.request.body_b64 + with pytest.raises(Denied) as error: + harness.complete(request=changed) + assert error.value.reason == "IDEMPOTENCY_CONFLICT" + assert harness.calls == [(CHECKOUT, 1)] + assert harness.merchant.counts() == (1, 1) + + +def test_concurrent_identical_requests_have_one_handler_call(harness: Harness) -> None: + barrier = Barrier(8) + + def complete() -> Bundle: + barrier.wait(timeout=5) + return harness.complete() + + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(lambda _: complete(), range(8))) + assert len({result.model_dump_json() for result in results}) == 1 + assert all( + audit(result, harness.keys.trust(), NOW).invocation_number == 1 for result in results + ) + assert harness.calls == [(CHECKOUT, 1)] + assert harness.merchant.counts() == (1, 1) + + +def test_concurrent_fresh_keys_still_spend_grant_once(harness: Harness) -> None: + barrier = Barrier(8) + requests = [completion_request(harness.keys, NOW, CHECKOUT) for _ in range(8)] + + def complete(request: SignedMessage) -> Bundle | str: + barrier.wait(timeout=5) + try: + return harness.complete(request=request) + except Denied as error: + return error.reason + + with ThreadPoolExecutor(max_workers=8) as pool: + results = list(pool.map(complete, requests)) + allowed = [result for result in results if isinstance(result, Bundle)] + assert len(allowed) == 1 + assert results.count("GRANT_SPENT") == 7 + assert audit(allowed[0], harness.keys.trust(), NOW).invocation_number == 1 + assert harness.calls == [(CHECKOUT, 1)] + assert harness.merchant.counts() == (1, 1) + + +def test_parallel_purchases_receipts_match_each_own_invocation(harness: Harness) -> None: + second_id = "checkout-002" + harness.merchant.seed_checkout(sample_checkout(second_id)) + inputs = [ + (harness.grant, harness.quote, harness.request), + ( + make_grant(harness.keys, NOW, grant_id="grant-two"), + harness.merchant.quote(second_id), + completion_request(harness.keys, NOW, second_id), + ), + ] + barrier = Barrier(2) + + def complete(arguments: tuple) -> Bundle: + barrier.wait(timeout=5) + return harness.merchant.complete(*arguments) + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(complete, inputs)) + decisions = [audit(result, harness.keys.trust(), NOW) for result in results] + assert {decision.invocation_number for decision in decisions} == {1, 2} + with sqlite3.connect(harness.merchant.path) as connection: + rows = connection.execute( + "SELECT s.grant_id, s.checkout_id, s.number, e.number " + "FROM spends AS s JOIN effects AS e ON s.checkout_id=e.checkout_id" + ).fetchall() + for decision in decisions: + assert ( + decision.grant_id, + decision.checkout_id, + decision.invocation_number, + decision.invocation_number, + ) in rows + assert (decision.checkout_id, decision.invocation_number) in harness.calls + assert len(harness.calls) == 2 + assert harness.merchant.counts() == (2, 2) + with pytest.raises(AuditError): + audit( + results[0].model_copy(update={"receipt": results[1].receipt}), harness.keys.trust(), NOW + ) + + +@pytest.mark.parametrize("role", ["merchant", "platform", "authority", "receipt"]) +def test_auditor_requires_each_independently_configured_trust_key( + harness: Harness, role: str +) -> None: + bundle = harness.complete() + wrong = getattr(Keys.generate().trust(), role) + with pytest.raises(AuditError, match="^purchase evidence rejected$"): + audit(bundle, replace(harness.keys.trust(), **{role: wrong}), NOW) + + +@pytest.mark.parametrize( + "component", ["grant", "checkout", "request", "response", "runtime_record"] +) +def test_auditor_rejects_cross_bound_signed_artifact_substitution( + harness: Harness, component: str +) -> None: + bundle = harness.complete() + if component == "grant": + value = changed_grant(harness, grant_id="different-valid-grant") + elif component == "checkout": + value = signed_quote(harness, sample_checkout(amount_minor=12_000)) + elif component == "request": + value = completion_request(harness.keys, NOW, CHECKOUT) + elif component == "response": + value = signed_quote(harness, sample_checkout("other-checkout", status="completed")) + else: + value = create_trace({"policy": POLICY}, harness.keys.receipt, NOW - timedelta(seconds=1)) + verify_trace(value, harness.keys.trust().receipt, NOW, {"policy": POLICY}) + with pytest.raises(AuditError, match="^purchase evidence rejected$"): + audit(bundle.model_copy(update={component: value}), harness.keys.trust(), NOW) + + +def test_audit_authenticates_controller_assertion_not_database_state(harness: Harness) -> None: + bundle = harness.complete() + payload = verify_decision(bundle.receipt, harness.keys.trust().receipt, RECEIPT_KID) + payload["invocation_number"] += 1 + receipt = sign_decision(payload, harness.keys.receipt, RECEIPT_KID) + # A trusted controller can sign a different invocation claim; without + # independent DB evidence this is an assertion, not proof of execution. + assert ( + audit( + bundle.model_copy(update={"receipt": receipt}), harness.keys.trust(), NOW + ).invocation_number + == 2 + ) + assert harness.calls == [(CHECKOUT, 1)] + assert harness.merchant.counts() == (1, 1) + + +def test_receipt_payload_modification_without_resigning_is_rejected(harness: Harness) -> None: + bundle = harness.complete() + payload = verify_decision(bundle.receipt, harness.keys.trust().receipt, RECEIPT_KID) + payload["invocation_number"] += 1 + changed = sign_decision(payload, harness.keys.receipt, RECEIPT_KID) + original_parts = bundle.receipt.split(".") + original_parts[1] = changed.split(".")[1] + with pytest.raises(AuditError): + audit( + bundle.model_copy(update={"receipt": ".".join(original_parts)}), + harness.keys.trust(), + NOW, + ) + + +@pytest.mark.parametrize( + "field,value", + [ + ("unknown", "field"), + ("invocation_number", "1"), + ("invocation_number", True), + ("decided_at", str(int(NOW.timestamp()))), + ("decision", "deny"), + ("merchant_key_sha256", "0" * 64), + ("policy_sha256", "0" * 64), + ], +) +def test_correctly_signed_receipt_unknown_fields_types_and_bindings_rejected( + harness: Harness, field: str, value: Any +) -> None: + bundle = harness.complete() + payload = verify_decision(bundle.receipt, harness.keys.trust().receipt, RECEIPT_KID) + payload[field] = value + receipt = sign_decision(payload, harness.keys.receipt, RECEIPT_KID) + with pytest.raises(AuditError): + audit(bundle.model_copy(update={"receipt": receipt}), harness.keys.trust(), NOW) + + +@pytest.mark.parametrize("offset", [-1, 301]) +def test_audit_clock_is_external_and_does_not_mutate_execution( + harness: Harness, offset: int +) -> None: + bundle = harness.complete() + with pytest.raises(AuditError): + audit(bundle, harness.keys.trust(), NOW + timedelta(seconds=offset)) + assert harness.calls == [(CHECKOUT, 1)] + assert harness.merchant.counts() == (1, 1) + + +def test_generic_pre_handler_exception_is_sanitized( + harness: Harness, monkeypatch: pytest.MonkeyPatch +) -> None: + def broken(*args: Any, **kwargs: Any) -> None: + raise RuntimeError("internal-secret-do-not-emit") + + monkeypatch.setattr(flow, "_authorize", broken) + with pytest.raises(Exception) as error: + harness.complete() + assert not isinstance(error.value, Denied) + assert type(error.value).__name__ == "TransactionAborted" + assert str(error.value) == "TRANSACTION_ABORTED" + assert error.value.__suppress_context__ is True + assert harness.calls == [] + assert harness.merchant.counts() == (0, 0) + + +def test_database_open_error_is_sanitized_before_handler( + harness: Harness, monkeypatch: pytest.MonkeyPatch +) -> None: + def broken() -> None: + raise sqlite3.OperationalError("private-path-do-not-emit") + + with monkeypatch.context() as patch: + patch.setattr(harness.merchant, "_connection", broken) + with pytest.raises(Exception) as error: + harness.complete() + assert not isinstance(error.value, Denied) + assert type(error.value).__name__ == "TransactionAborted" + assert str(error.value) == "TRANSACTION_ABORTED" + assert error.value.__suppress_context__ is True + assert harness.calls == [] + assert harness.merchant.counts() == (0, 0) + + +@pytest.mark.parametrize("exception_kind", ["runtime", "late_denied"]) +def test_post_handler_signing_failure_rolls_back_without_denial_claim( + harness: Harness, monkeypatch: pytest.MonkeyPatch, exception_kind: str +) -> None: + def broken(*args: Any, **kwargs: Any) -> None: + if exception_kind == "late_denied": + raise Denied("must-not-be-returned-as-denial-after-handler") + raise RuntimeError("signing-secret-do-not-emit") + + with monkeypatch.context() as patch: + patch.setattr(flow, "sign_decision", broken) + with pytest.raises(Exception) as error: + harness.complete() + assert not isinstance(error.value, Denied) + assert type(error.value).__name__ == "TransactionAborted" + assert str(error.value) == "TRANSACTION_ABORTED" + assert error.value.__suppress_context__ is True + assert harness.calls == [(CHECKOUT, 1)] + assert harness.merchant.counts() == (0, 0) + with sqlite3.connect(harness.merchant.path) as connection: + assert connection.execute("SELECT count(*) FROM retries").fetchone()[0] == 0 + checkout = json.loads( + connection.execute("SELECT body FROM checkouts WHERE id=?", (CHECKOUT,)).fetchone()[0] + ) + assert checkout["status"] == "ready_for_complete" + # Retrying after the injected failure performs a new attempt. Only its local + # effect commits: two handler entries do NOT mean two committed purchases. + assert audit(harness.complete(), harness.keys.trust(), NOW).invocation_number == 1 + assert len(harness.calls) == 2 + assert harness.merchant.counts() == (1, 1) + + +def test_expiry_rechecked_after_sqlite_lock_wait( + harness: Harness, monkeypatch: pytest.MonkeyPatch +) -> None: + attempting_lock = Event() + time_guard = Lock() + current_time = NOW + + def clock() -> datetime: + with time_guard: + return current_time + + class ObservedConnection(sqlite3.Connection): + def execute(self, sql: str, parameters: Any = (), /) -> sqlite3.Cursor: + if sql == "BEGIN IMMEDIATE": + attempting_lock.set() + return super().execute(sql, parameters) + + monkeypatch.setattr(harness.merchant, "clock", clock) + monkeypatch.setattr( + harness.merchant, + "_connection", + lambda: sqlite3.connect(harness.merchant.path, timeout=5, factory=ObservedConnection), + ) + blocker = sqlite3.connect(harness.merchant.path) + blocker.execute("BEGIN IMMEDIATE") + try: + with ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(harness.complete) + try: + assert attempting_lock.wait(timeout=3) + with time_guard: + current_time = NOW + timedelta(seconds=241) + finally: + blocker.rollback() + with pytest.raises(Denied) as error: + future.result(timeout=5) + finally: + blocker.close() + assert error.value.reason == "GRANT_EXPIRED" + assert harness.calls == [] + assert harness.merchant.counts() == (0, 0) + + +@pytest.mark.parametrize("kind", ["grant", "http_signature"]) +def test_pre_handler_final_freshness_recheck( + harness: Harness, monkeypatch: pytest.MonkeyPatch, kind: str +) -> None: + calls = 0 + future_time = NOW + timedelta(seconds=240 if kind == "grant" else 300) + token = harness.grant + if kind == "http_signature": + token = changed_grant(harness, expires_at=int(NOW.timestamp()) + 600) + + def clock() -> datetime: + nonlocal calls + calls += 1 + return NOW if calls == 1 else future_time + + monkeypatch.setattr(harness.merchant, "clock", clock) + harness.assert_denial( + "GRANT_EXPIRED" if kind == "grant" else "AUTHENTICATION_FAILED", token=token + ) diff --git a/examples/agentic-commerce-accountability/released_ucp/tests/test_schema.py b/examples/agentic-commerce-accountability/released_ucp/tests/test_schema.py new file mode 100644 index 00000000..3c877b30 --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/tests/test_schema.py @@ -0,0 +1,115 @@ +"""Released schema and SDK checks, deliberately independent of authorization.""" + +import copy +import hashlib +import json +from typing import Any + +import pytest +from jsonschema import Draft202012Validator + +from ucp_commerce.schema import ( + SCHEMA_DIRECTORY, + SchemaValidationError, + sample_checkout, + sample_complete_request, + validate_checkout, + validate_complete_request, +) + + +@pytest.mark.parametrize("status", ["ready_for_complete", "completed"]) +def test_checkout_passes_released_schema_and_sdk_without_mutation(status: str) -> None: + checkout = sample_checkout(status=status) + original = copy.deepcopy(checkout) + assert validate_checkout(checkout) is None + assert checkout == original + + +def test_complete_request_passes_released_schema_and_sdk_without_payment_data() -> None: + request = sample_complete_request() + assert request == {"payment": {"instruments": []}} + assert validate_complete_request(request) is None + + +@pytest.mark.parametrize( + "field", ["ucp", "id", "line_items", "status", "currency", "totals", "links"] +) +def test_checkout_rejects_missing_released_required_field(field: str) -> None: + checkout = sample_checkout() + del checkout[field] + with pytest.raises(SchemaValidationError, match="invalid UCP checkout response"): + validate_checkout(checkout) + + +def test_completion_requires_payment_not_the_response_shape() -> None: + with pytest.raises(SchemaValidationError, match="invalid UCP completion request"): + validate_complete_request({}) + with pytest.raises(SchemaValidationError): + validate_complete_request(sample_checkout()) + + +@pytest.mark.parametrize("amount", ["12500", True, -1, 1.5]) +def test_checkout_rejects_invalid_amount_before_sdk_coercion(amount: Any) -> None: + checkout = sample_checkout() + checkout["line_items"][0]["item"]["price"] = amount + with pytest.raises(SchemaValidationError): + validate_checkout(checkout) + + +def test_checkout_requires_exactly_one_total_and_subtotal() -> None: + checkout = sample_checkout() + checkout["totals"].append({"type": "total", "amount": 12500}) + with pytest.raises(SchemaValidationError): + validate_checkout(checkout) + checkout = sample_checkout() + checkout["totals"] = [{"type": "total", "amount": 12500}] + with pytest.raises(SchemaValidationError): + validate_checkout(checkout) + + +def test_checkout_rejects_unknown_status_and_invalid_link_uri() -> None: + checkout = sample_checkout(status="fictional_status") + with pytest.raises(SchemaValidationError): + validate_checkout(checkout) + checkout = sample_checkout() + checkout["links"][0]["url"] = "not a URI" + with pytest.raises(SchemaValidationError): + validate_checkout(checkout) + + +def test_validation_errors_do_not_echo_payload() -> None: + request = {"payment": "private-customer-material-not-for-errors"} + with pytest.raises(SchemaValidationError) as caught: + validate_complete_request(request) + assert str(caught.value) == "invalid UCP completion request" + assert caught.value.__suppress_context__ is True + + +def test_schema_acceptance_is_not_authorization_or_extension_negotiation() -> None: + checkout = sample_checkout(amount_minor=99999999) + checkout["example_unknown_member"] = "ignored by the released open schema" + assert validate_checkout(checkout) is None + + +def _references(value: Any) -> list[str]: + if isinstance(value, dict): + return [str(item) for key, item in value.items() if key == "$ref"] + [ + ref for item in value.values() for ref in _references(item) + ] + if isinstance(value, list): + return [ref for item in value for ref in _references(item)] + return [] + + +def test_vendored_schemas_are_valid_offline_and_match_provenance() -> None: + provenance = json.loads((SCHEMA_DIRECTORY / "provenance.json").read_text()) + assert provenance["ucp_source"]["commit"] == "cd78fb38e819de77d9b527d110476eccb876f1bd" + assert provenance["resolver"]["version"] == "1.4.1" + for filename, expected_hash in provenance["artifact_sha256"].items(): + raw = (SCHEMA_DIRECTORY / filename).read_bytes() + assert hashlib.sha256(raw).hexdigest() == expected_hash + if filename.endswith(".json"): + schema = json.loads(raw) + Draft202012Validator.check_schema(schema) + assert all(ref.startswith("#") for ref in _references(schema)) diff --git a/examples/agentic-commerce-accountability/released_ucp/tests/test_wire.py b/examples/agentic-commerce-accountability/released_ucp/tests/test_wire.py new file mode 100644 index 00000000..941d9290 --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/tests/test_wire.py @@ -0,0 +1,448 @@ +"""Offline defensive regressions using real RFC 9421 / ES256 operations.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 +from http_message_signatures import ( + HTTPMessageSigner, + HTTPSignatureKeyResolver, + algorithms, + http_sfv, +) +from pydantic import ValidationError + +from ucp_commerce.wire import ( + MAX_BODY_BYTES, + SignedMessage, + WireError, + public_key_digest, + raw_body, + sign_request, + sign_response, + verify_request, + verify_response, +) + +NOW = datetime(2026, 9, 7, 12, tzinfo=UTC) +URL = "https://merchant.example/checkouts/chk_example/complete" +KID = "platform-example" +IDEMPOTENCY = "f3ef412de1be1e4f7e36e6d6031696cd" +BODY = {"payment": {"instruments": []}} + + +@pytest.fixture +def key() -> ec.EllipticCurvePrivateKey: + return ec.generate_private_key(ec.SECP256R1()) + + +def request(key: ec.EllipticCurvePrivateKey, **kwargs: object) -> SignedMessage: + options = dict( + method="POST", + url=URL, + body=BODY, + private_key=key, + kid=KID, + idempotency_key=IDEMPOTENCY, + now=NOW, + ) + options.update(kwargs) + return sign_request(**options) + + +def replace_header(message: SignedMessage, name: str, value: str) -> SignedMessage: + headers = message.header_map() + headers[name] = value + return message.model_copy(update={"headers": tuple(headers.items())}) + + +def resign( + message: SignedMessage, + key: ec.EllipticCurvePrivateKey, + *, + covered: tuple[str, ...] | None = None, + body: bytes | None = None, + created: datetime = NOW, + expires: datetime | None = NOW + timedelta(minutes=5), + include_alg: bool = False, +) -> SignedMessage: + """Produce independently configured signatures with the released library. + + This is test construction, not a mock of the production verifier or signer. + """ + + class Resolver(HTTPSignatureKeyResolver): + def resolve_private_key(self, key_id: str) -> ec.EllipticCurvePrivateKey: + assert key_id == KID + return key + + headers = message.header_map() + for name in ("signature", "signature-input"): + headers.pop(name, None) + payload = raw_body(message) if body is None else body + headers["content-digest"] = ( + "sha-256=:" + base64.b64encode(hashlib.sha256(payload).digest()).decode() + ":" + ) + transport = SimpleNamespace(method=message.method, url=message.url, headers=headers) + if message.status is not None: + transport.status_code = message.status + transport.request = SimpleNamespace(method=message.method) + if covered is None: + covered = ( + "@method", + "@authority", + "@path", + "content-type", + "content-digest", + "idempotency-key", + ) + if message.status is not None: + covered = ("@status", "content-type", "content-digest") + HTTPMessageSigner( + signature_algorithm=algorithms.ECDSA_P256_SHA256, key_resolver=Resolver() + ).sign( + transport, + key_id=KID, + created=created, + expires=expires, + include_alg=include_alg, + covered_component_ids=covered, + ) + return SignedMessage( + **message.model_dump(exclude={"headers", "body_b64"}), + headers=tuple((name.lower(), value) for name, value in transport.headers.items()), + body_b64=base64.b64encode(payload).decode(), + ) + + +def test_request_round_trip_uses_raw_r_s_and_no_alg(key: ec.EllipticCurvePrivateKey) -> None: + message = request(key) + assert verify_request(message, key.public_key(), KID, NOW) == BODY + assert raw_body(message) == b'{"payment":{"instruments":[]}}' + assert ";alg=" not in message.header("signature-input") + signature = http_sfv.Dictionary() + signature.parse(message.header("signature").encode()) + assert len(signature["pyhms"].value) == 64 + assert SignedMessage.model_validate_json(message.model_dump_json()) == message + + +def test_response_round_trip_binds_status_and_raw_body(key: ec.EllipticCurvePrivateKey) -> None: + message = sign_response( + request(key), 200, {"id": "chk_example", "status": "completed"}, key, KID, NOW + ) + assert verify_response(message, key.public_key(), KID, NOW)["id"] == "chk_example" + with pytest.raises(WireError): + verify_response(message.model_copy(update={"status": 201}), key.public_key(), KID, NOW) + with pytest.raises(WireError): + verify_request(message, key.public_key(), KID, NOW) + with pytest.raises(WireError): + verify_response(request(key), key.public_key(), KID, NOW) + + +def test_response_does_not_authenticate_request_metadata(key: ec.EllipticCurvePrivateKey) -> None: + message = sign_response(request(key), 200, {"id": "chk_example"}, key, KID, NOW) + moved = message.model_copy(update={"url": "https://other.example/other", "method": "DELETE"}) + # This is deliberately NOT a denial assertion. Default UCP response signatures + # do not bind URL/method: the business/audit layer must bind checkout IDs. + assert verify_response(moved, key.public_key(), KID, NOW) == {"id": "chk_example"} + + +@pytest.mark.parametrize( + "update", + [ + {"method": "DELETE"}, + {"url": "https://other.example/checkouts/chk_example/complete"}, + {"url": "https://merchant.example/checkouts/different/complete"}, + {"url": URL + "?currency=GBP"}, + ], +) +def test_request_target_substitutions_are_denied( + key: ec.EllipticCurvePrivateKey, update: dict +) -> None: + with pytest.raises(WireError): + verify_request(request(key).model_copy(update=update), key.public_key(), KID, NOW) + + +def test_query_and_profile_header_are_covered(key: ec.EllipticCurvePrivateKey) -> None: + message = request( + key, url=URL + "?view=full", ucp_agent="https://platform.example/.well-known/ucp" + ) + assert verify_request(message, key.public_key(), KID, NOW) == BODY + with pytest.raises(WireError): + verify_request( + message.model_copy(update={"url": URL + "?view=brief"}), key.public_key(), KID, NOW + ) + changed = replace_header( + message, "ucp-agent", 'profile="https://other.example/.well-known/ucp"' + ) + with pytest.raises(WireError): + verify_request(changed, key.public_key(), KID, NOW) + + +@pytest.mark.parametrize( + "missing", + ["@method", "@authority", "@path", "content-type", "content-digest", "idempotency-key"], +) +def test_valid_signature_with_insufficient_request_coverage_is_denied( + key: ec.EllipticCurvePrivateKey, missing: str +) -> None: + components = ( + "@method", + "@authority", + "@path", + "content-type", + "content-digest", + "idempotency-key", + ) + message = resign( + request(key), key, covered=tuple(item for item in components if item != missing) + ) + with pytest.raises(WireError): + verify_request(message, key.public_key(), KID, NOW) + + +@pytest.mark.parametrize("missing", ["@status", "content-type", "content-digest"]) +def test_valid_signature_with_insufficient_response_coverage_is_denied( + key: ec.EllipticCurvePrivateKey, missing: str +) -> None: + response = sign_response(request(key), 200, {"id": "chk_example"}, key, KID, NOW) + message = resign( + response, + key, + covered=tuple( + item for item in ("@status", "content-type", "content-digest") if item != missing + ), + ) + with pytest.raises(WireError): + verify_response(message, key.public_key(), KID, NOW) + + +@pytest.mark.parametrize( + "kwargs", [{"url": URL + "?view=full"}, {"ucp_agent": "https://platform.example/profile"}] +) +def test_conditional_query_and_profile_coverage_required( + key: ec.EllipticCurvePrivateKey, kwargs: dict +) -> None: + message = resign(request(key, **kwargs), key) + with pytest.raises(WireError): + verify_request(message, key.public_key(), KID, NOW) + + +@pytest.mark.parametrize( + "profile", + [ + "profile=unquoted", + 'profile="http://platform.example/profile"', + 'missing="https://platform.example/profile"', + 'profile="https://platform.example/profile";unexpected', + ], +) +def test_signed_malformed_profile_header_is_denied( + key: ec.EllipticCurvePrivateKey, profile: str +) -> None: + message = replace_header(request(key), "ucp-agent", profile) + signed = resign( + message, + key, + covered=( + "@method", + "@authority", + "@path", + "content-type", + "content-digest", + "idempotency-key", + "ucp-agent", + ), + ) + with pytest.raises(WireError): + verify_request(signed, key.public_key(), KID, NOW) + + +def test_digest_checks_raw_bytes_not_equivalent_json(key: ec.EllipticCurvePrivateKey) -> None: + message = request(key) + changed = message.model_copy( + update={"body_b64": base64.b64encode(json.dumps(BODY).encode()).decode()} + ) + assert json.loads(raw_body(changed)) == BODY + with pytest.raises(WireError): + verify_request(changed, key.public_key(), KID, NOW) + # Freshly signing those same bytes makes them valid; canonical JSON is NOT a + # wire requirement. Durable JCS records are a separate layer. + assert ( + verify_request(resign(message, key, body=raw_body(changed)), key.public_key(), KID, NOW) + == BODY + ) + + +def test_key_id_does_not_select_an_untrusted_key(key: ec.EllipticCurvePrivateKey) -> None: + message = request(key) + with pytest.raises(WireError): + verify_request(message, ec.generate_private_key(ec.SECP256R1()).public_key(), KID, NOW) + with pytest.raises(WireError): + verify_request(message, key.public_key(), "other-trusted-key-id", NOW) + assert ( + public_key_digest(key.public_key()) + == hashlib.sha256( + key.public_key().public_bytes( + serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo + ) + ).hexdigest() + ) + + +@pytest.mark.parametrize("delta", [-0.01, 300, 301]) +def test_strict_local_freshness_no_wall_clock_dependency( + key: ec.EllipticCurvePrivateKey, delta: float +) -> None: + with pytest.raises(WireError): + verify_request(request(key), key.public_key(), KID, NOW + timedelta(seconds=delta)) + assert ( + verify_request(request(key), key.public_key(), KID, NOW + timedelta(seconds=299.9)) == BODY + ) + + +@pytest.mark.parametrize( + "options", + [ + {"expires": None}, + {"expires": NOW}, + {"expires": NOW + timedelta(seconds=301)}, + {"include_alg": True}, + {"created": NOW + timedelta(seconds=1)}, + ], +) +def test_correctly_signed_invalid_local_time_or_algorithm_parameters_denied( + key: ec.EllipticCurvePrivateKey, options: dict +) -> None: + with pytest.raises(WireError): + verify_request(resign(request(key), key, **options), key.public_key(), KID, NOW) + + +@pytest.mark.parametrize( + "payload", + [ + b'{"a":1,"a":2}', + b'{"a":{"nested":1,"nested":2}}', + b"[]", + b"null", + b'{"number":NaN}', + b'{"number":Infinity}', + b'{"number":1e999}', + b"\xff", + b"{", + ], +) +def test_authenticated_invalid_or_ambiguous_json_denied( + key: ec.EllipticCurvePrivateKey, payload: bytes +) -> None: + with pytest.raises(WireError, match="^HTTP message verification failed$"): + verify_request(resign(request(key), key, body=payload), key.public_key(), KID, NOW) + + +@pytest.mark.parametrize( + "update", + [ + {"status": "200"}, + {"status": True}, + {"method": "post"}, + {"body_b64": "%%%"}, + {"url": "http://merchant.example/path"}, + {"url": "https://user:secret@merchant.example/path"}, + {"url": "https://merchant.example/path#fragment"}, + {"url": "https://merchant.example"}, + {"url": "https://merchant.example/path%GG"}, + {"url": "https://merchant.example/path\r\n"}, + ], +) +def test_copied_models_revalidated_and_invalid_snapshot_denied( + key: ec.EllipticCurvePrivateKey, update: dict +) -> None: + with pytest.raises(WireError): + verify_request(request(key).model_copy(update=update), key.public_key(), KID, NOW) + + +def test_headers_deeply_immutable_and_duplicates_unknowns_controls_denied( + key: ec.EllipticCurvePrivateKey, +) -> None: + message = request(key) + with pytest.raises(ValidationError): + message.method = "DELETE" + with pytest.raises(TypeError): + message.headers[0] = ("content-type", "text/plain") + copied = message.header_map() + copied["content-type"] = "text/plain" + assert message.header("content-type") == "application/json" + for header in ( + ("content-type", "application/json"), + ("x-unknown", "value"), + ("UCP-Agent", "value"), + ("ucp-agent", "value\nsecret"), + ): + with pytest.raises(WireError): + verify_request( + message.model_copy(update={"headers": (*message.headers, header)}), + key.public_key(), + KID, + NOW, + ) + data = message.model_dump() + data["unknown"] = "not permitted" + with pytest.raises(ValidationError): + SignedMessage.model_validate(data) + + +def test_limits_idempotency_content_type_and_naive_clock(key: ec.EllipticCurvePrivateKey) -> None: + for idem in (None, "short"): + with pytest.raises(WireError): + request(key, idempotency_key=idem) + with pytest.raises(ValueError): + request(key, body={"oversize": "x" * MAX_BODY_BYTES}) + with pytest.raises(WireError): + request(key, now=NOW.replace(tzinfo=None)) + message = request(key) + with pytest.raises(WireError): + verify_request(message, key.public_key(), KID, NOW.replace(tzinfo=None)) + with pytest.raises(WireError): + verify_request( + replace_header(message, "content-type", "text/plain"), key.public_key(), KID, NOW + ) + headers = tuple((name, value) for name, value in message.headers if name != "idempotency-key") + with pytest.raises(WireError): + verify_request(message.model_copy(update={"headers": headers}), key.public_key(), KID, NOW) + + +def test_wrong_curve_and_key_type_are_rejected(key: ec.EllipticCurvePrivateKey) -> None: + for other in (ec.generate_private_key(ec.SECP384R1()), ed25519.Ed25519PrivateKey.generate()): + with pytest.raises(WireError): + request(other) + with pytest.raises(WireError): + verify_request(request(key), other.public_key(), KID, NOW) + + +def test_no_payload_or_key_material_in_verification_errors(key: ec.EllipticCurvePrivateKey) -> None: + message = request(key, body={"private": "do-not-disclose"}) + changed = replace_header(message, "signature-input", "do-not-disclose-invalid-header") + with pytest.raises(WireError) as error: + verify_request(changed, key.public_key(), KID, NOW) + assert str(error.value) == "HTTP message verification failed" + assert error.value.__suppress_context__ is True + + +def test_get_and_empty_query_profile_and_invalid_json_signing( + key: ec.EllipticCurvePrivateKey, +) -> None: + message = request(key, method="GET", idempotency_key=None, url=URL + "?") + assert verify_request(message, key.public_key(), KID, NOW) == BODY + with pytest.raises(ValueError): + request(key, body={"not_json": object()}) + with pytest.raises(ValueError): + request(key, body={"not_json": float("nan")}) + with pytest.raises(WireError): + sign_response(sign_response(request(key), 200, {}, key, KID, NOW), 200, {}, key, KID, NOW) diff --git a/examples/agentic-commerce-accountability/released_ucp/ucp_commerce/__init__.py b/examples/agentic-commerce-accountability/released_ucp/ucp_commerce/__init__.py new file mode 100644 index 00000000..f8cd8e0a --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/ucp_commerce/__init__.py @@ -0,0 +1 @@ +"""One-merchant, software-only released-UCP accountability example.""" diff --git a/examples/agentic-commerce-accountability/released_ucp/ucp_commerce/__main__.py b/examples/agentic-commerce-accountability/released_ucp/ucp_commerce/__main__.py new file mode 100644 index 00000000..72409841 --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/ucp_commerce/__main__.py @@ -0,0 +1,167 @@ +"""Offline demonstration and independently selected public-key audit entry point.""" + +from __future__ import annotations + +import argparse +import json +import tempfile +from pathlib import Path + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 +from pydantic import BaseModel, ConfigDict, Field + +from .flow import ( + Bundle, + Denied, + Keys, + Merchant, + Trust, + audit, + completion_request, + make_grant, + utc_now, +) +from .schema import UCP_VERSION, sample_checkout + + +class PublicTrust(BaseModel): + """Explicit local trust configuration, NOT keys discovered from a bundle.""" + + model_config = ConfigDict(strict=True, extra="forbid") + merchant: str = Field(max_length=2048) + platform: str = Field(max_length=2048) + authority: str = Field(max_length=2048) + receipt: str = Field(max_length=2048) + + @classmethod + def from_trust(cls, trust: Trust) -> PublicTrust: + def pem(key: ec.EllipticCurvePublicKey | ed25519.Ed25519PublicKey) -> str: + return key.public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo + ).decode("ascii") + + return cls( + merchant=pem(trust.merchant), + platform=pem(trust.platform), + authority=pem(trust.authority), + receipt=pem(trust.receipt), + ) + + def trust(self) -> Trust: + merchant = serialization.load_pem_public_key(self.merchant.encode("ascii")) + platform = serialization.load_pem_public_key(self.platform.encode("ascii")) + authority = serialization.load_pem_public_key(self.authority.encode("ascii")) + receipt = serialization.load_pem_public_key(self.receipt.encode("ascii")) + if not ( + isinstance(merchant, ec.EllipticCurvePublicKey) + and isinstance(merchant.curve, ec.SECP256R1) + and isinstance(platform, ec.EllipticCurvePublicKey) + and isinstance(platform.curve, ec.SECP256R1) + and isinstance(authority, ed25519.Ed25519PublicKey) + and isinstance(receipt, ed25519.Ed25519PublicKey) + ): + raise ValueError("unsupported configured public keys") + configured = Trust(merchant, platform, authority, receipt) + # cryptography's parser can ignore trailing PEM blocks. Require exactly + # the canonical public SPKI document, with no appended key or other data. + if self != PublicTrust.from_trust(configured): + raise ValueError("configured fields must contain one canonical public key") + return configured + + +def _read(path: Path, limit: int) -> str: + with path.open("rb") as source: + data = source.read(limit + 1) + if len(data) > limit: + raise ValueError("input exceeds example size limit") + return data.decode("utf-8") + + +def _demo(output: Path | None) -> dict: + # Private keys exist only in memory; the temporary local DB is never exported. + keys = Keys.generate() + now = utc_now() + with tempfile.TemporaryDirectory(prefix="released-ucp-") as temporary: + merchant = Merchant(Path(temporary) / "merchant.sqlite", keys) + merchant.seed_checkout(sample_checkout()) + quote = merchant.quote("checkout-001") + grant = make_grant(keys, now) + request = completion_request(keys, now, "checkout-001") + bundle = merchant.complete(grant, quote, request) + decision = audit(bundle, keys.trust(), utc_now()) + retry = merchant.complete(grant, quote, request) + try: + merchant.complete(grant, quote, completion_request(keys, utc_now(), "checkout-001")) + except Denied as refusal: + replay = refusal.reason + else: + raise RuntimeError("one-purchase invariant failed") + spends, effects = merchant.counts() + if replay != "GRANT_SPENT" or (spends, effects) != (1, 1) or retry != bundle: + raise RuntimeError("retry invariant failed") + if output is not None: + # Deliberately do not overwrite a previous evidence/trust selection. + output.mkdir(mode=0o700, parents=True, exist_ok=False) + (output / "bundle.json").write_text(bundle.model_dump_json(indent=2) + "\n") + (output / "public-trust.json").write_text( + PublicTrust.from_trust(keys.trust()).model_dump_json(indent=2) + "\n" + ) + return { + "ucp_release": UCP_VERSION, + "decision": decision.decision, + "amount_minor": 12500, + "currency": "USD", + "invocation_number": decision.invocation_number, + "spends": spends, + "effects": effects, + "identical_retry_cached": retry == bundle, + "fresh_token_replay": replay, + "trace": [ + "released JSON Schemas and SDK models validate both UCP bodies", + "configured keys verify ES256 HTTP signatures and raw-body digests", + "user authority permits this merchant/platform, USD cap and operation", + "current checkout matches the authenticated quoted terms", + "one SQL spend, simulated effect and signed result commit together", + "read-only audit rechecks grant, messages, software TRACE and decision", + ], + "assurance": ( + "Local simulated purchase only: no payment, hardware attestation, " + "exact-price user consent, or independent proof of execution." + ), + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--output", type=Path, help="new directory for public demonstration evidence" + ) + commands = parser.add_subparsers(dest="command") + verifier = commands.add_parser( + "audit", help="read-only audit within the local 300-second window" + ) + verifier.add_argument("bundle", type=Path) + verifier.add_argument( + "--trust", type=Path, required=True, help="independently selected public keys" + ) + arguments = parser.parse_args(argv) + try: + if arguments.command == "audit": + if arguments.output is not None: + raise ValueError("audit does not export evidence") + trust = PublicTrust.model_validate_json(_read(arguments.trust, 16_384)).trust() + bundle = Bundle.model_validate_json(_read(arguments.bundle, 1_048_576)) + result = audit(bundle, trust, utc_now()).model_dump() + else: + result = _demo(arguments.output) + print(json.dumps(result, indent=2)) + return 0 + except Exception: + # No raw submitted artifacts, filesystem paths or cryptographic errors. + print(json.dumps({"error": "example operation failed"})) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/agentic-commerce-accountability/released_ucp/ucp_commerce/evidence.py b/examples/agentic-commerce-accountability/released_ucp/ucp_commerce/evidence.py new file mode 100644 index 00000000..578ea664 --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/ucp_commerce/evidence.py @@ -0,0 +1,293 @@ +"""Local application authority, not a normative UCP or payment mandate. + +The configured grant key is an external trust input. A valid grant signature +authenticates the exact fields below, not whether a user consented in a UI, a +merchant fulfilled an order, or a payment settled. The controller and auditor +must separately check expiry, request bindings, scope, amount, and one-spend +state. Neither a JWS ``kid`` nor a key supplied in a transcript establishes trust. +""" + +from __future__ import annotations + +import json +from datetime import datetime +from hashlib import sha256 +from pathlib import Path +from typing import Any, Literal +from urllib.parse import urlsplit + +import rfc8785 +from agentrust_trace import ( + TRACE_PROFILE_V0_2, + TrustRecord, + sign_record, + validate_json, + verify_record, +) +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) +from joserfc import jws +from joserfc.jwk import OKPKey +from pydantic import BaseModel, ConfigDict, Field, field_validator + +GRANT_ALGORITHM = "Ed25519" +GRANT_TYPE = "example-ucp-authority+jws" +DECISION_TYPE = "example-ucp-decision+jws" +MAX_SAFE_INTEGER = 9_007_199_254_740_991 +MAX_UNIX_TIME = 253_402_300_799 # 9999-12-31T23:59:59Z, not an unbounded expiry. +TRACE_SUBJECT = "did:example:released-ucp-local-controller" +TRACE_MAX_AGE_SECONDS = 300 +TRACE_APPRAISAL_VERIFIER = "urn:example:appraisal-not-performed" + + +class EvidenceError(ValueError): + """An evidence object failed local verification; no raw payload is returned.""" + + +class AuthorityGrant(BaseModel): + """Closed, non-coercing authority format scoped only to this local example.""" + + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + grant_id: str = Field(pattern=r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") + merchant_origin: str = Field(min_length=1, max_length=2048) + merchant_key_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + platform_key_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + currency: str = Field(pattern=r"^[A-Z]{3}$") + max_amount_minor: int = Field(gt=0, le=MAX_SAFE_INTEGER) + operation: Literal["checkout.complete"] + expires_at: int = Field(gt=0, le=MAX_UNIX_TIME) + + @field_validator("merchant_origin") + @classmethod + def origin_only(cls, value: str) -> str: + """Require a literal HTTP(S) origin, without silently normalizing it.""" + parsed = urlsplit(value) + if ( + parsed.scheme not in {"http", "https"} + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.path + or parsed.query + or parsed.fragment + or value != f"{parsed.scheme}://{parsed.netloc}" + or any(character.isspace() for character in value) + ): + raise ValueError("merchant_origin must be an exact HTTP(S) origin") + _ = parsed.port # Reject malformed or out-of-range port syntax. + return value + + +def _protected(kid: str) -> dict[str, str]: + if not isinstance(kid, str) or not kid or len(kid) > 128: + raise EvidenceError("invalid configured grant key identifier") + return {"alg": GRANT_ALGORITHM, "kid": kid, "typ": GRANT_TYPE} + + +def sign_grant(grant: AuthorityGrant, key: Ed25519PrivateKey, kid: str) -> str: + """Sign a revalidated grant as RFC 8785 canonical JSON in compact JWS.""" + if not isinstance(key, Ed25519PrivateKey): + raise EvidenceError("grant signing requires an Ed25519 private key") + # Revalidation also refuses model_construct/model_copy bypasses on the caller. + validated = AuthorityGrant.model_validate(grant.model_dump(warnings=False)) + return jws.serialize_compact( + _protected(kid), + rfc8785.dumps(validated.model_dump()), + OKPKey.import_key(key), + algorithms=[GRANT_ALGORITHM], + ) + + +def verify_grant(token: str, key: Ed25519PublicKey, kid: str) -> AuthorityGrant: + """Authenticate a canonical, strictly typed grant against configured trust. + + This deliberately has no clock or business context. Its caller must enforce + the signed expiry and other grant constraints before any business action. + """ + if not isinstance(key, Ed25519PublicKey): + raise EvidenceError("grant verification requires a trusted Ed25519 public key") + expected = _protected(kid) + try: + if not isinstance(token, str) or not token or len(token) > 8192: + raise ValueError("invalid compact JWS size or type") + signed = jws.deserialize_compact( + token, OKPKey.import_key(key), algorithms=[GRANT_ALGORITHM] + ) + if signed.protected != expected: + raise ValueError("unexpected protected header") + payload = json.loads(signed.payload) + if not isinstance(payload, dict) or rfc8785.dumps(payload) != signed.payload: + raise ValueError("payload must be a canonical JSON object") + return AuthorityGrant.model_validate(payload) + except Exception: + # This is a refusal at the evidence boundary. Do not leak a JOSE error, + # attacker-controlled payload, or validation input through diagnostics. + raise EvidenceError("invalid authority grant") from None + + +def _digest(value: Any) -> str: + return "sha256:" + sha256(rfc8785.dumps(value)).hexdigest() + + +def sign_decision(payload: dict[str, Any], key: Ed25519PrivateKey, kid: str) -> str: + """Sign the caller's local decision object; its model belongs to the auditor.""" + if not isinstance(payload, dict) or not isinstance(key, Ed25519PrivateKey): + raise EvidenceError("decision signing requires an object and Ed25519 private key") + return jws.serialize_compact( + {**_protected(kid), "typ": DECISION_TYPE}, + rfc8785.dumps(payload), + OKPKey.import_key(key), + algorithms=[GRANT_ALGORITHM], + ) + + +def verify_decision(token: str, key: Ed25519PublicKey, kid: str) -> dict[str, Any]: + """Authenticate canonical decision bytes; the auditor must validate its model. + + This helper does not authorize execution or interpret business fields. It + neither coerces signed values nor treats an embedded key as a trust source. + """ + if not isinstance(key, Ed25519PublicKey): + raise EvidenceError("decision verification requires a trusted Ed25519 public key") + expected = {**_protected(kid), "typ": DECISION_TYPE} + try: + if not isinstance(token, str) or not token or len(token) > 65_536: + raise ValueError("invalid compact JWS size or type") + signed = jws.deserialize_compact( + token, OKPKey.import_key(key), algorithms=[GRANT_ALGORITHM] + ) + if signed.protected != expected: + raise ValueError("unexpected protected header") + payload = json.loads(signed.payload) + if not isinstance(payload, dict) or rfc8785.dumps(payload) != signed.payload: + raise ValueError("payload must be a canonical JSON object") + return payload + except Exception: + raise EvidenceError("invalid decision receipt") from None + + +def _trace_epoch(now: datetime) -> int: + if not isinstance(now, datetime) or now.tzinfo is None or now.utcoffset() is None: + raise EvidenceError("TRACE evaluation requires an aware datetime") + timestamp = int(now.timestamp()) + if not 1_700_000_000 <= timestamp <= MAX_UNIX_TIME: + raise EvidenceError("TRACE evaluation time is outside the example profile") + return timestamp + + +def create_trace( + transcript: dict[str, Any], key: Ed25519PrivateKey, now: datetime +) -> dict[str, Any]: + """Use released TRACE signing for the local software policy assertion. + + The outer local JWS decision binds this record's digest to UCP business + evidence. UCP HTTP is not a MCP/A2A tool transcript, so this record does not + overload ``tool_transcript`` or invent a reference resolver. + The required measurement/build digest is the source of this helper, locally + read by the signer, not a hardware measurement or proof that this code ran. + ``transcript['policy']`` names the local controller's enforced application + policy. There is no model, transparency anchor, or affirming appraisal. + """ + if not isinstance(transcript, dict) or not isinstance(key, Ed25519PrivateKey): + raise EvidenceError("TRACE creation requires a transcript and Ed25519 private key") + if not isinstance(transcript.get("policy"), dict) or not transcript["policy"]: + raise EvidenceError("TRACE creation requires the local application policy") + source_digest = "sha256:" + sha256(Path(__file__).read_bytes()).hexdigest() + payload: dict[str, Any] = { + "eat_profile": TRACE_PROFILE_V0_2, + "iat": _trace_epoch(now), + "subject": TRACE_SUBJECT, + "model": {"provider": "none", "model_id": "no-model-used"}, + "runtime": {"platform": "software-only", "measurement": source_digest}, + "policy": { + "bundle_hash": _digest(transcript["policy"]), + "enforcement_mode": "enforce", + }, + "data_class": "synthetic-example", + "origin": {"kind": "self", "producer": "released-ucp-local-controller"}, + "build_provenance": {"slsa_level": 0, "digest": source_digest}, + "appraisal": {"status": "none", "verifier": TRACE_APPRAISAL_VERIFIER}, + } + record = sign_record(payload, key) + validate_json(record) + TrustRecord.model_validate(record, strict=True) + return record + + +def verify_trace( + record: dict[str, Any], + key: Ed25519PublicKey, + now: datetime, + transcript: dict[str, Any], + *, + revoked_key_ids: frozenset[str] = frozenset(), +) -> None: + """Verify released TRACE shape/signature, local trust, age, and policy hash. + + Revocation is checked against the caller's local list (empty for the fresh + disposable example keys). This is not a published revocation service or a + freshness-checked revocation bundle. Success never establishes payment, + execution, hardware provenance, or the truth of the recorded observations. + The business auditor must still verify the outer decision and every included + signature and binding; this TRACE record alone does not bind all HTTP data. + """ + if not isinstance(key, Ed25519PublicKey): + raise EvidenceError("TRACE verification requires a trusted Ed25519 public key") + try: + if not isinstance(record, dict) or not isinstance(transcript, dict): + raise ValueError("TRACE and transcript must be objects") + if not isinstance(transcript.get("policy"), dict) or not transcript["policy"]: + raise ValueError("local application policy is required") + if not isinstance(revoked_key_ids, frozenset) or any( + not isinstance(identifier, str) for identifier in revoked_key_ids + ): + raise ValueError("invalid configured revocation list") + validate_json(record) + TrustRecord.model_validate(record, strict=True) + result = verify_record( + record, + key, + allow_embedded_key=False, + max_age_seconds=TRACE_MAX_AGE_SECONDS, + max_future_skew_seconds=0, + revocation=revoked_key_ids, + now=_trace_epoch(now), + ) + if result.revocation.outcome != "verified" or result.revocation.evidence != { + "source": "store" + }: + raise ValueError("local revocation check was not performed") + if ( + set(record) + != { + "eat_profile", + "iat", + "subject", + "model", + "runtime", + "policy", + "data_class", + "origin", + "build_provenance", + "appraisal", + "cnf", + "signature", + } + or record["subject"] != TRACE_SUBJECT + or record["model"] != {"provider": "none", "model_id": "no-model-used"} + or record["runtime"]["platform"] != "software-only" + or set(record["runtime"]) != {"platform", "measurement"} + or record["data_class"] != "synthetic-example" + or record["policy"] + != {"bundle_hash": _digest(transcript["policy"]), "enforcement_mode": "enforce"} + or record["appraisal"] != {"status": "none", "verifier": TRACE_APPRAISAL_VERIFIER} + or record["origin"] != {"kind": "self", "producer": "released-ucp-local-controller"} + or record["build_provenance"] + != {"slsa_level": 0, "digest": record["runtime"]["measurement"]} + ): + raise ValueError("TRACE differs from the local observation profile") + except Exception: + raise EvidenceError("invalid TRACE observation record") from None diff --git a/examples/agentic-commerce-accountability/released_ucp/ucp_commerce/flow.py b/examples/agentic-commerce-accountability/released_ucp/ucp_commerce/flow.py new file mode 100644 index 00000000..0f203aa3 --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/ucp_commerce/flow.py @@ -0,0 +1,538 @@ +"""A local merchant boundary and an independently rerunnable evidence auditor. + +The only business effect is an SQLite row, not a payment. Authorization, the +simulated effect, and the cached signed result commit in one transaction. This +does NOT implement exactly-once execution across an external payment system. +""" + +from __future__ import annotations + +import hashlib +import json +import re +import secrets +import sqlite3 +from collections.abc import Callable +from contextlib import closing +from copy import deepcopy +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, Literal + +import rfc8785 +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 +from pydantic import BaseModel, ConfigDict, Field + +from .evidence import ( + AuthorityGrant, + EvidenceError, + create_trace, + sign_decision, + sign_grant, + verify_decision, + verify_grant, + verify_trace, +) +from .schema import ( + UCP_VERSION, + SchemaValidationError, + sample_checkout, + sample_complete_request, + validate_checkout, + validate_complete_request, +) +from .wire import ( + SignedMessage, + WireError, + public_key_digest, + raw_body, + sign_request, + sign_response, + verify_request, + verify_response, +) + +MERCHANT = "https://merchant.example" +MERCHANT_KID = "example-merchant" +PLATFORM_KID = "example-platform" +PLATFORM_PROFILE = "https://platform.example/.well-known/ucp" +GRANT_KID = "example-user-authority" +RECEIPT_KID = "example-controller-receipt" +POLICY = { + "id": "urn:example:ucp-one-purchase:v1", + "ucp_version": UCP_VERSION, + "merchant_origin": MERCHANT, + "platform_profile": PLATFORM_PROFILE, + "operation": "checkout.complete", + "currency": "USD", + "checkout_profile": "one-fictional-reservation-v1", + "payment_profile": "empty-instruments-no-payment", + "grant_redemption": "one-local-purchase-per-grant-id", + "http_signature_max_age_seconds": 300, + "idempotency": "client-and-target-scoped-raw-body-sha256", + "idempotency_retention": "database-lifetime-no-automatic-pruning", +} + + +def digest(value: Any) -> str: + return hashlib.sha256(rfc8785.dumps(value)).hexdigest() + + +def body_digest(message: SignedMessage) -> str: + return hashlib.sha256(raw_body(message)).hexdigest() + + +def utc_now() -> datetime: + return datetime.now(UTC) + + +class Denied(ValueError): + """Stable refusal reason; never carries submitted payloads or raw exceptions.""" + + def __init__(self, reason: str): + self.reason = reason + super().__init__(reason) + + +class AuditError(ValueError): + pass + + +class TransactionAborted(RuntimeError): + """A local transaction failed; this is not an authorization denial. + + The handler may have been entered. Its SQL effects do not commit on error. + This guarantee would not apply to external I/O performed by another handler. + """ + + reason = "TRANSACTION_ABORTED" + + def __init__(self) -> None: + super().__init__(self.reason) + + +class Decision(BaseModel): + """Example-local receipt, not a new normative UCP/TRACE schema.""" + + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + profile: Literal["example-ucp-decision-v1"] + decision: Literal["allow"] + decided_at: int = Field(gt=0) + invocation_number: int = Field(gt=0) + grant_id: str + checkout_id: str + policy_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + grant_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + checkout_body_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + request_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + request_body_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + response_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + runtime_record_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + merchant_key_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + platform_key_sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + + +class Bundle(BaseModel): + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + grant: str = Field(max_length=8192) + checkout: SignedMessage + request: SignedMessage + response: SignedMessage + runtime_record: dict[str, Any] + receipt: str = Field(max_length=32768) + + +@dataclass(frozen=True) +class Trust: + """Public keys supplied independently, never adopted from an evidence bundle.""" + + merchant: ec.EllipticCurvePublicKey + platform: ec.EllipticCurvePublicKey + authority: ed25519.Ed25519PublicKey + receipt: ed25519.Ed25519PublicKey + + +@dataclass(frozen=True) +class Keys: + merchant: ec.EllipticCurvePrivateKey + platform: ec.EllipticCurvePrivateKey + authority: ed25519.Ed25519PrivateKey + receipt: ed25519.Ed25519PrivateKey + + @classmethod + def generate(cls) -> Keys: + return cls( + ec.generate_private_key(ec.SECP256R1()), + ec.generate_private_key(ec.SECP256R1()), + ed25519.Ed25519PrivateKey.generate(), + ed25519.Ed25519PrivateKey.generate(), + ) + + def trust(self) -> Trust: + return Trust( + self.merchant.public_key(), + self.platform.public_key(), + self.authority.public_key(), + self.receipt.public_key(), + ) + + +def _checkout_profile(checkout: dict[str, Any], status: str) -> tuple[str, int]: + """Schema validity does not imply this example supports every UCP extension.""" + validate_checkout(checkout) + identifier = checkout.get("id") + if not isinstance(identifier, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,80}", identifier): + raise Denied("UNSUPPORTED_CHECKOUT") + totals = [total for total in checkout["totals"] if total["type"] == "total"] + if len(totals) != 1 or type(totals[0]["amount"]) is not int: + raise Denied("UNSUPPORTED_CHECKOUT") + amount = totals[0]["amount"] + # Explicitly small supported profile: do not silently drop signed extensions, + # alternate totals, currency, fulfillment constraints or payment instruments. + if amount < 0 or checkout != sample_checkout(identifier, amount, status): + raise Denied("UNSUPPORTED_CHECKOUT") + return identifier, amount + + +def _authorize( + token: str, + checkout_message: SignedMessage, + request: SignedMessage, + trust: Trust, + now: datetime, + *, + check_request_profile: bool = True, +) -> tuple[AuthorityGrant, dict[str, Any], str, int]: + """Non-mutating authentication and spending-policy evaluation.""" + try: + grant = verify_grant(token, trust.authority, GRANT_KID) + checkout = verify_response(checkout_message, trust.merchant, MERCHANT_KID, now) + request_body = verify_request(request, trust.platform, PLATFORM_KID, now) + if request.header("ucp-agent") != f'profile="{PLATFORM_PROFILE}"': + raise Denied("PLATFORM_PROFILE") + if check_request_profile: + validate_complete_request(request_body) + if request_body != sample_complete_request(): + raise Denied("UNSUPPORTED_PAYMENT_PROFILE") + checkout_id, amount = _checkout_profile(checkout, "ready_for_complete") + if checkout_message.status != 200: + raise Denied("CHECKOUT_STATUS") + # Response URL/method are transport annotations, NOT authenticated UCP + # response components. Never use them as merchant/checkout authority. + target = f"{MERCHANT}/checkout-sessions/{checkout_id}/complete" + if request.method != "POST" or request.url != target: + raise Denied("REQUEST_TARGET") + if grant.merchant_origin != MERCHANT or ( + grant.merchant_key_sha256 != public_key_digest(trust.merchant) + ): + raise Denied("MERCHANT_AUTHORITY") + if grant.platform_key_sha256 != public_key_digest(trust.platform): + raise Denied("PLATFORM_AUTHORITY") + if grant.currency != "USD": + raise Denied("CURRENCY") + if now.timestamp() >= grant.expires_at: + raise Denied("GRANT_EXPIRED") + if amount > grant.max_amount_minor: + raise Denied("OVERSPEND") + return grant, checkout, checkout_id, amount + except (EvidenceError, WireError): + raise Denied("AUTHENTICATION_FAILED") from None + except SchemaValidationError: + raise Denied("SCHEMA_INVALID") from None + + +def _decision( + bundle: Bundle, + trust: Trust, + grant: AuthorityGrant, + checkout_id: str, + decided_at: int, + invocation_number: int, +) -> Decision: + return Decision( + profile="example-ucp-decision-v1", + decision="allow", + decided_at=decided_at, + invocation_number=invocation_number, + grant_id=grant.grant_id, + checkout_id=checkout_id, + policy_sha256=digest(POLICY), + grant_sha256=digest(grant.model_dump()), + checkout_body_sha256=body_digest(bundle.checkout), + request_sha256=digest(bundle.request.model_dump(mode="json")), + request_body_sha256=body_digest(bundle.request), + response_sha256=digest(bundle.response.model_dump(mode="json")), + runtime_record_sha256=digest(bundle.runtime_record), + merchant_key_sha256=public_key_digest(trust.merchant), + platform_key_sha256=public_key_digest(trust.platform), + ) + + +def audit(bundle: Bundle, trust: Trust, now: datetime) -> Decision: + """Read-only audit, separate from execution. Never spends or trusts embedded keys. + + Re-evaluation authenticates recorded statements under configured example keys; + it is not independent proof of physical execution, payment, or settlement. + """ + try: + bundle = Bundle.model_validate_json(bundle.model_dump_json()) + decision = Decision.model_validate( + verify_decision(bundle.receipt, trust.receipt, RECEIPT_KID) + ) + # Verify freshness at the caller's current time, not a timestamp chosen by + # the record. This online-age policy is not a historical archive verifier. + if not 0 <= now.timestamp() - decision.decided_at <= 300: + raise ValueError("receipt outside local freshness window") + at_decision = datetime.fromtimestamp(decision.decided_at, UTC) + grant, checkout, checkout_id, amount = _authorize( + bundle.grant, + bundle.checkout, + bundle.request, + trust, + at_decision, + ) + completed = verify_response(bundle.response, trust.merchant, MERCHANT_KID, at_decision) + if bundle.response.status != 200: + raise ValueError("completion not successful") + final_id, final_amount = _checkout_profile(completed, "completed") + if final_id != checkout_id or final_amount != amount: + raise ValueError("completion differs from approved checkout") + expected_completed = deepcopy(checkout) + expected_completed["status"] = "completed" + expected_completed["order"] = sample_checkout(checkout_id, amount, "completed")["order"] + if completed != expected_completed: + raise ValueError("completed checkout changed purchase terms") + verify_trace(bundle.runtime_record, trust.receipt, at_decision, {"policy": POLICY}) + expected = _decision( + bundle, + trust, + grant, + checkout_id, + decision.decided_at, + decision.invocation_number, + ) + if decision != expected: + raise ValueError("decision does not bind supplied evidence") + return decision + except Exception: + raise AuditError("purchase evidence rejected") from None + + +class Merchant: + """One SQLite-backed simulated merchant. Every completion enters this boundary.""" + + def __init__(self, path: Path, keys: Keys, clock: Callable[[], datetime] = utc_now): + self.path, self.clock = path, clock + # The harness constructs all roles in one process, not isolated services. + # The merchant retains only its own signing keys, not the user/platform keys. + self._merchant_key, self._receipt_key = keys.merchant, keys.receipt + self.trust = keys.trust() + with closing(self._connection()) as connection, connection: + connection.executescript(""" + CREATE TABLE IF NOT EXISTS checkouts (id TEXT PRIMARY KEY, body TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS spends ( + number INTEGER PRIMARY KEY AUTOINCREMENT, + grant_id TEXT NOT NULL UNIQUE, checkout_id TEXT NOT NULL UNIQUE + ); + CREATE TABLE IF NOT EXISTS effects ( + number INTEGER PRIMARY KEY, checkout_id TEXT NOT NULL UNIQUE + ); + CREATE TABLE IF NOT EXISTS retries ( + client TEXT NOT NULL, target TEXT NOT NULL, token TEXT NOT NULL, + body_digest TEXT NOT NULL, result TEXT NOT NULL, + PRIMARY KEY(client, target, token) + ); + """) + + def _connection(self) -> sqlite3.Connection: + return sqlite3.connect(self.path, timeout=5) + + def seed_checkout(self, checkout: dict[str, Any]) -> None: + """Fixture setup, not an untrusted network route. No replacement on conflict.""" + identifier, _ = _checkout_profile(checkout, "ready_for_complete") + with closing(self._connection()) as connection, connection: + connection.execute( + "INSERT INTO checkouts VALUES (?, ?)", (identifier, json.dumps(checkout)) + ) + + def quote(self, identifier: str) -> SignedMessage: + with closing(self._connection()) as connection: + row = connection.execute( + "SELECT body FROM checkouts WHERE id=?", (identifier,) + ).fetchone() + if row is None: + raise Denied("CHECKOUT_UNKNOWN") + now = self.clock() + request = sign_request( + "GET", + f"{MERCHANT}/checkout-sessions/{identifier}", + {}, + self._merchant_key, + MERCHANT_KID, + None, + now, + ) + # This internal GET supplies response transport annotations only. The + # completion POST below is the actual authenticated platform request. + return sign_response( + request, 200, json.loads(row[0]), self._merchant_key, MERCHANT_KID, now + ) + + def counts(self) -> tuple[int, int]: + with closing(self._connection()) as connection: + return ( + connection.execute("SELECT count(*) FROM spends").fetchone()[0], + connection.execute("SELECT count(*) FROM effects").fetchone()[0], + ) + + def _complete_checkout( + self, + connection: sqlite3.Connection, + checkout: dict[str, Any], + invocation: int, + ) -> dict[str, Any]: + """The entire simulated business effect. NEVER performs external I/O.""" + identifier, amount = _checkout_profile(checkout, "ready_for_complete") + completed = sample_checkout(identifier, amount, "completed") + connection.execute("INSERT INTO effects VALUES (?, ?)", (invocation, identifier)) + connection.execute( + "UPDATE checkouts SET body=? WHERE id=?", (json.dumps(completed), identifier) + ) + return completed + + def complete(self, token: str, checkout: SignedMessage, request: SignedMessage) -> Bundle: + # INV-1: the only exposed completion path verifies authority before the + # private handler. Tests assert both DB counters and actual handler calls. + connection = None + handler_started = False + try: + connection = self._connection() + connection.execute("BEGIN IMMEDIATE") + # INV-2: lock waits cannot leave us authorizing with a pre-lock clock. + now = self.clock() + grant, terms, identifier, _ = _authorize( + token, checkout, request, self.trust, now, check_request_profile=False + ) + client = public_key_digest(self.trust.platform) + retry_key = request.header("idempotency-key") + cached = connection.execute( + "SELECT body_digest, result FROM retries WHERE client=? AND target=? AND token=?", + (client, request.url, retry_key), + ).fetchone() + # INV-3: RFC 9530 RAW body matching, not semantic JSON equivalence. + # Identical scoped retries return the original signed result. + if cached is not None: + if cached[0] != body_digest(request): + raise Denied("IDEMPOTENCY_CONFLICT") + result = Bundle.model_validate_json(cached[1]) + connection.rollback() + return result + # Authenticate before examining a cached response, but compare raw + # retry bodies before interpreting a changed body's payment profile. + # For a new operation, validate its released schema and local profile. + _authorize(token, checkout, request, self.trust, now) + if connection.execute( + "SELECT 1 FROM spends WHERE grant_id=?", (grant.grant_id,) + ).fetchone(): + raise Denied("GRANT_SPENT") + current = connection.execute( + "SELECT body FROM checkouts WHERE id=?", (identifier,) + ).fetchone() + if current is None: + raise Denied("CHECKOUT_UNKNOWN") + # INV-4: approval of an old, correctly signed checkout is not approval + # of the merchant's current state. Compare under the same write lock. + if digest(json.loads(current[0])) != digest(terms): + raise Denied("STALE_CHECKOUT") + # Recheck ALL time-bound inputs immediately before the spend/effect, + # not just the grant. A slow preflight must not extend HTTP freshness. + now = self.clock() + _authorize(token, checkout, request, self.trust, now) + cursor = connection.execute( + "INSERT INTO spends(grant_id, checkout_id) VALUES (?, ?)", + (grant.grant_id, identifier), + ) + invocation = int(cursor.lastrowid or 0) + handler_started = True + completed = self._complete_checkout(connection, terms, invocation) + response = sign_response(request, 200, completed, self._merchant_key, MERCHANT_KID, now) + record = create_trace({"policy": POLICY}, self._receipt_key, now) + draft = Bundle( + grant=token, + checkout=checkout, + request=request, + response=response, + runtime_record=record, + receipt="", + ) + decision = _decision( + draft, self.trust, grant, identifier, int(now.timestamp()), invocation + ) + result = draft.model_copy( + update={ + "receipt": sign_decision(decision.model_dump(), self._receipt_key, RECEIPT_KID), + } + ) + audit(result, self.trust, now) + connection.execute( + "INSERT INTO retries VALUES (?, ?, ?, ?, ?)", + ( + client, + request.url, + retry_key, + body_digest(request), + result.model_dump_json(), + ), + ) + # INV-5: spend, local effect and cached receipt commit together. On a + # signing/audit/storage error ALL local rows roll back. This is only + # possible because there is no external merchant/payment side effect. + connection.commit() + return result + except Denied: + if connection is not None: + connection.rollback() + if handler_started: + raise TransactionAborted() from None + raise + except Exception: + if connection is not None: + connection.rollback() + raise TransactionAborted() from None + finally: + if connection is not None: + connection.close() + + +def make_grant( + keys: Keys, now: datetime, *, grant_id: str | None = None, limit: int = 20000 +) -> str: + grant = AuthorityGrant( + grant_id=grant_id or secrets.token_hex(16), + merchant_origin=MERCHANT, + merchant_key_sha256=public_key_digest(keys.merchant.public_key()), + platform_key_sha256=public_key_digest(keys.platform.public_key()), + currency="USD", + max_amount_minor=limit, + operation="checkout.complete", + expires_at=int(now.timestamp()) + 240, + ) + return sign_grant(grant, keys.authority, GRANT_KID) + + +def completion_request( + keys: Keys, now: datetime, identifier: str, token: str | None = None +) -> SignedMessage: + return sign_request( + "POST", + f"{MERCHANT}/checkout-sessions/{identifier}/complete", + sample_complete_request(), + keys.platform, + PLATFORM_KID, + token or secrets.token_hex(16), + now, + ucp_agent=PLATFORM_PROFILE, + ) diff --git a/examples/agentic-commerce-accountability/released_ucp/ucp_commerce/schema.py b/examples/agentic-commerce-accountability/released_ucp/ucp_commerce/schema.py new file mode 100644 index 00000000..c43e78d3 --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/ucp_commerce/schema.py @@ -0,0 +1,120 @@ +"""Offline validation of the pinned UCP checkout shapes, not purchase authorization. + +The JSON Schemas are generated by the released upstream annotation resolver. +Validate the original object before the SDK can coerce any values, then exercise +the official released SDK model too. Neither result authenticates a merchant, +authorizes a payment, or establishes negotiation of an extension capability. +""" + +from __future__ import annotations + +import json +from functools import lru_cache +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator, FormatChecker +from jsonschema.exceptions import ValidationError as JsonSchemaValidationError +from pydantic import ValidationError as ModelValidationError +from referencing import Registry +from ucp_sdk.models.schemas.shopping.checkout import Checkout +from ucp_sdk.models.schemas.shopping.checkout_complete_request import CheckoutCompleteRequest + +UCP_VERSION = "2026-08-25" +SCHEMA_DIRECTORY = Path(__file__).resolve().parents[1] / "schemas" + + +class SchemaValidationError(ValueError): + """The payload does not match the pinned released UCP shape.""" + + +@lru_cache(maxsize=2) +def _validator(filename: str) -> Draft202012Validator: + schema = json.loads((SCHEMA_DIRECTORY / filename).read_text(encoding="utf-8")) + Draft202012Validator.check_schema(schema) + return Draft202012Validator( + schema, + format_checker=FormatChecker(), + # Registry's default retrieval raises NoSuchResource; unlike the legacy + # jsonschema resolver it does not fetch an unknown URI from the network. + registry=Registry(), + ) + + +def validate_checkout(payload: dict[str, Any]) -> None: + """Validate a base checkout response without modifying or authenticating it.""" + try: + _validator("checkout-response.json").validate(payload) + Checkout.model_validate(payload) + except (JsonSchemaValidationError, ModelValidationError): + # Public validation errors must not echo customer or payment data. + raise SchemaValidationError("invalid UCP checkout response") from None + + +def validate_complete_request(payload: dict[str, Any]) -> None: + """Validate the completion request shape, not its spending authority.""" + try: + _validator("checkout-complete-request.json").validate(payload) + CheckoutCompleteRequest.model_validate(payload) + except (JsonSchemaValidationError, ModelValidationError): + raise SchemaValidationError("invalid UCP completion request") from None + + +def sample_checkout( + checkout_id: str = "checkout-001", + amount_minor: int = 12500, + status: str = "ready_for_complete", +) -> dict[str, Any]: + """One fictional unextended checkout; no real merchant or payment instrument.""" + checkout: dict[str, Any] = { + "ucp": { + "version": UCP_VERSION, + "status": "success", + "capabilities": { + "dev.ucp.shopping.checkout": [ + { + "version": UCP_VERSION, + "schema": "https://ucp.dev/schemas/shopping/checkout.json", + } + ] + }, + "payment_handlers": {}, + }, + "id": checkout_id, + "status": status, + "currency": "USD", + "line_items": [ + { + "id": "line-example-001", + "item": { + "id": "item-example-001", + "title": "Fictional example reservation", + "price": amount_minor, + }, + "quantity": 1, + "totals": [ + {"type": "subtotal", "amount": amount_minor}, + {"type": "total", "amount": amount_minor}, + ], + } + ], + "totals": [ + {"type": "subtotal", "amount": amount_minor}, + {"type": "total", "amount": amount_minor}, + ], + "links": [ + {"type": "terms_of_service", "url": "https://merchant.example/terms"}, + {"type": "privacy_policy", "url": "https://merchant.example/privacy"}, + ], + } + if status == "completed": + checkout["order"] = { + "id": "order-example-001", + "permalink_url": "https://merchant.example/orders/order-example-001", + } + return checkout + + +def sample_complete_request() -> dict[str, Any]: + """The released base schema permits empty instruments; no payment is processed.""" + return {"payment": {"instruments": []}} diff --git a/examples/agentic-commerce-accountability/released_ucp/ucp_commerce/wire.py b/examples/agentic-commerce-accountability/released_ucp/ucp_commerce/wire.py new file mode 100644 index 00000000..5e723059 --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/ucp_commerce/wire.py @@ -0,0 +1,418 @@ +"""Offline UCP 2026-08-25 REST signatures, not a network/key-discovery client. + +RFC 9421 signature construction and ES256 verification belong to the released +http-message-signatures library. This layer checks RFC 9530 raw-body digests and +the components the application actually needs. It deliberately supports one +externally configured P-256 key, one signature, JSON object bodies, and HTTPS. +The required five-minute validity window is LOCAL POLICY, not a UCP requirement. + +A standard UCP response authenticates status and body, NOT its enclosing method +or URL. Consumers must independently bind its checkout/order identifiers to the +expected request. Neither a signature nor this clock check redeems an entitlement +or supplies durable idempotency; that belongs to the business boundary. +""" + +from __future__ import annotations + +import base64 +import binascii +import hashlib +import hmac +import json +import math +import re +from datetime import datetime, timedelta +from types import SimpleNamespace +from typing import Any, Self +from urllib.parse import urlsplit + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from http_message_signatures import ( + HTTPMessageSigner, + HTTPMessageVerifier, + HTTPSignatureKeyResolver, + algorithms, + http_sfv, +) +from http_message_signatures.exceptions import HTTPMessageSignaturesException +from http_message_signatures.structures import CaseInsensitiveDict +from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, model_validator + +MAX_BODY_BYTES = 65_536 +MAX_SIGNATURE_AGE = timedelta(minutes=5) +_MAX_ENCODED_BODY = 4 * ((MAX_BODY_BYTES + 2) // 3) +_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE"}) +_MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) +_HEADERS = frozenset( + { + "content-type", + "content-digest", + "idempotency-key", + "ucp-agent", + "signature-input", + "signature", + } +) +_IDENTIFIER = re.compile(r"[A-Za-z0-9._~-]{1,128}\Z") +_JSON_OBJECT = TypeAdapter(dict[str, JsonValue]) + + +class WireError(ValueError): + """A bounded wire-profile failure; contains no underlying payload/key data.""" + + +def _check_url(url: str) -> None: + parsed = urlsplit(url) + if ( + not url.isascii() + or any(ord(char) <= 32 or ord(char) == 127 for char in url) + or "\\" in url + or re.search(r"[^A-Za-z0-9:/?\[\]@!$&'()*+,;=%._~-]", url) + or re.search(r"%(?![A-Fa-f0-9]{2})", url) + or parsed.scheme != "https" + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + or parsed.fragment + or "#" in url + or not parsed.path.startswith("/") + or parsed.port == 0 + ): + raise ValueError("unsupported HTTPS target") + + +class SignedMessage(BaseModel): + """Immutable transport snapshot; the snapshot itself is not a signed envelope. + + Headers are immutable name/value pairs, serialized as JSON arrays. Use + ``header_map()`` for a detached dictionary. No unknown headers are permitted + by this small profile; in particular it does not implement WBA discovery. + """ + + model_config = ConfigDict(extra="forbid", strict=True, frozen=True) + + method: str = Field(min_length=1, max_length=8) + url: str = Field(min_length=1, max_length=2048) + status: int | None + headers: tuple[tuple[str, str], ...] = Field(min_length=1, max_length=8) + body_b64: str = Field(min_length=1, max_length=_MAX_ENCODED_BODY) + + @model_validator(mode="after") + def validate_snapshot(self) -> Self: + if self.method not in _METHODS: + raise ValueError("unsupported HTTP method") + _check_url(self.url) + if self.status is not None and not 200 <= self.status <= 599: + raise ValueError("unsupported HTTP status") + names: set[str] = set() + for name, value in self.headers: + if name not in _HEADERS or name in names: + raise ValueError("unknown, duplicate, or non-lowercase header") + if not 1 <= len(value) <= 4096 or not value.isascii(): + raise ValueError("invalid header value") + if any(ord(char) < 32 or ord(char) == 127 for char in value): + raise ValueError("invalid header value") + names.add(name) + try: + decoded = base64.b64decode(self.body_b64, validate=True) + except (ValueError, binascii.Error): + raise ValueError("invalid body encoding") from None + if len(decoded) > MAX_BODY_BYTES or base64.b64encode(decoded).decode() != self.body_b64: + raise ValueError("invalid body size or encoding") + return self + + def header(self, name: str) -> str | None: + return dict(self.headers).get(name.lower()) + + def header_map(self) -> dict[str, str]: + return dict(self.headers) + + +def _snapshot(message: SignedMessage) -> SignedMessage: + # model_copy/model_construct bypass Pydantic validation. Trust no such object + # merely because its Python type is SignedMessage. + if not isinstance(message, SignedMessage): + raise WireError("invalid message") + return SignedMessage.model_validate(message.model_dump(warnings=False)) + + +def raw_body(message: SignedMessage) -> bytes: + """Return bounded raw bytes, without implying signature authentication.""" + try: + return base64.b64decode(_snapshot(message).body_b64, validate=True) + except (ValueError, TypeError): + raise WireError("invalid message") from None + + +def _timestamp(now: datetime) -> float: + if not isinstance(now, datetime) or now.utcoffset() is None: + raise WireError("timezone-aware evaluation time required") + return now.timestamp() + + +def _check_kid(kid: str) -> None: + if not isinstance(kid, str) or _IDENTIFIER.fullmatch(kid) is None: + raise WireError("invalid configured key identifier") + + +def _check_public_key(key: ec.EllipticCurvePublicKey) -> None: + if not isinstance(key, ec.EllipticCurvePublicKey) or not isinstance(key.curve, ec.SECP256R1): + raise WireError("configured key must be P-256") + + +def public_key_digest(key: ec.EllipticCurvePublicKey) -> str: + """SHA-256 of DER SubjectPublicKeyInfo, not a JWK thumbprint.""" + _check_public_key(key) + der = key.public_bytes( + serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo + ) + return hashlib.sha256(der).hexdigest() + + +class _PinnedKey(HTTPSignatureKeyResolver): + def __init__( + self, + kid: str, + public: ec.EllipticCurvePublicKey, + private: ec.EllipticCurvePrivateKey | None = None, + ) -> None: + _check_kid(kid) + _check_public_key(public) + self.kid, self.public, self.private = kid, public, private + + def resolve_public_key(self, key_id: str) -> ec.EllipticCurvePublicKey: + if key_id != self.kid: + raise WireError("untrusted signing key") + return self.public + + def resolve_private_key(self, key_id: str) -> ec.EllipticCurvePrivateKey: + if key_id != self.kid or self.private is None: + raise WireError("signing key unavailable") + return self.private + + +class _AtTimeVerifier(HTTPMessageVerifier): + """Override only the library's time-policy hook, not signature verification.""" + + def __init__(self, *, key_resolver: _PinnedKey, now: datetime) -> None: + super().__init__( + signature_algorithm=algorithms.ECDSA_P256_SHA256, key_resolver=key_resolver + ) + self.evaluation_time = _timestamp(now) + + def validate_created_and_expires(self, sig_input: Any, max_age: Any = None) -> None: + params = sig_input.params + # UCP derives the algorithm from the key; its wire form omits `alg`. + # This local profile does not claim WBA/multiple-signature support. + if set(params) != {"created", "expires", "keyid"}: + raise WireError("unsupported signature parameters") + created, expires = params["created"], params["expires"] + if type(created) is not int or type(expires) is not int: + raise WireError("invalid signature validity bounds") + if not ( + 0 <= created <= self.evaluation_time < expires + and 0 < expires - created <= MAX_SIGNATURE_AGE.total_seconds() + ): + raise WireError("signature outside validity window") + + +def _digest(body: bytes) -> str: + return "sha-256=:" + base64.b64encode(hashlib.sha256(body).digest()).decode() + ":" + + +def _transport(message: SignedMessage) -> SimpleNamespace: + transport = SimpleNamespace( + url=message.url, method=message.method, headers=CaseInsensitiveDict(message.header_map()) + ) + if message.status is not None: + transport.status_code = message.status + transport.request = SimpleNamespace(method=message.method) + return transport + + +def _required_components(message: SignedMessage) -> tuple[str, ...]: + if message.status is not None: + return ("@status", "content-digest", "content-type") + components = ["@method", "@authority", "@path"] + if "?" in message.url: + components.append("@query") + if message.header("ucp-agent") is not None: + components.append("ucp-agent") + if message.header("idempotency-key") is not None: + components.append("idempotency-key") + return (*components, "content-digest", "content-type") + + +def _check_profile_header(value: str) -> None: + profile = http_sfv.Dictionary() + profile.parse(value.encode("ascii")) + if set(profile) != {"profile"}: + raise WireError("unsupported UCP-Agent profile declaration") + item = profile["profile"] + if type(item.value) is not str or item.params: + raise WireError("invalid UCP-Agent profile declaration") + _check_url(item.value) + + +def _sign( + method: str, + url: str, + status: int | None, + body: dict[str, JsonValue], + private_key: ec.EllipticCurvePrivateKey, + kid: str, + headers: dict[str, str], + now: datetime, +) -> SignedMessage: + _timestamp(now) + if not isinstance(private_key, ec.EllipticCurvePrivateKey): + raise WireError("configured signing key must be P-256") + resolver = _PinnedKey(kid, private_key.public_key(), private_key) + # Deliberately no checkout-schema validation here: the consumer enforces its + # released schema, and tests must be able to sign schema-invalid JSON too. + data = _JSON_OBJECT.validate_python(body, strict=True) + encoded = json.dumps(data, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode() + headers.update({"content-type": "application/json", "content-digest": _digest(encoded)}) + message = SignedMessage( + method=method, + url=url, + status=status, + body_b64=base64.b64encode(encoded).decode(), + headers=tuple(headers.items()), + ) + transport = _transport(message) + HTTPMessageSigner(signature_algorithm=algorithms.ECDSA_P256_SHA256, key_resolver=resolver).sign( + transport, + key_id=kid, + created=now, + expires=now + MAX_SIGNATURE_AGE, + include_alg=False, + covered_component_ids=_required_components(message), + ) + return SignedMessage( + **message.model_dump(exclude={"headers"}), + headers=tuple((name.lower(), value) for name, value in transport.headers.items()), + ) + + +def sign_request( + method: str, + url: str, + body: dict[str, JsonValue], + private_key: ec.EllipticCurvePrivateKey, + kid: str, + idempotency_key: str | None, + now: datetime, + *, + ucp_agent: str | None = None, +) -> SignedMessage: + """Sign an offline REST request. UCP-Agent, when provided, is a profile URL.""" + headers: dict[str, str] = {} + if method in _MUTATING_METHODS and idempotency_key is None: + raise WireError("state-changing REST request requires idempotency key") + if idempotency_key is not None: + # Generation/uniqueness/entropy are caller responsibilities. Shape alone + # cannot establish 128 random bits; the example uses secrets.token_hex. + if re.fullmatch(r"[A-Za-z0-9_-]{22,128}", idempotency_key) is None: + raise WireError("invalid idempotency key") + headers["idempotency-key"] = idempotency_key + if ucp_agent is not None: + _check_url(ucp_agent) + headers["ucp-agent"] = str(http_sfv.Dictionary({"profile": ucp_agent})) + return _sign(method, url, None, body, private_key, kid, headers, now) + + +def sign_response( + request: SignedMessage, + status: int, + body: dict[str, JsonValue], + private_key: ec.EllipticCurvePrivateKey, + kid: str, + now: datetime, +) -> SignedMessage: + """Sign status/body; copying request metadata does NOT authenticate that metadata.""" + request = _snapshot(request) + if request.status is not None: + raise WireError("expected request") + return _sign(request.method, request.url, status, body, private_key, kid, {}, now) + + +def _no_duplicate_properties(pairs: list[tuple[str, JsonValue]]) -> dict[str, JsonValue]: + result: dict[str, JsonValue] = {} + for key, value in pairs: + if key in result: + raise WireError("duplicate JSON property") + result[key] = value + return result + + +def _reject_constant(value: str) -> None: + raise WireError("non-JSON numeric constant") + + +def _finite_float(value: str) -> float: + result = float(value) + if not math.isfinite(result): + raise WireError("non-finite JSON number") + return result + + +def _verify( + message: SignedMessage, + key: ec.EllipticCurvePublicKey, + kid: str, + now: datetime, + *, + response: bool, +) -> dict[str, JsonValue]: + try: + message = _snapshot(message) + if (message.status is not None) != response: + raise WireError("wrong message direction") + if message.header("content-type") != "application/json": + raise WireError("unsupported content type") + profile = message.header("ucp-agent") + if profile is not None: + _check_profile_header(profile) + if not response and message.method in _MUTATING_METHODS: + idem = message.header("idempotency-key") + if idem is None or re.fullmatch(r"[A-Za-z0-9_-]{22,128}", idem) is None: + raise WireError("missing or invalid idempotency key") + result = _AtTimeVerifier(key_resolver=_PinnedKey(kid, key), now=now).verify( + _transport(message) + ) + if len(result) != 1: + raise WireError("expected one signature") + required = {'"' + item + '"' for item in _required_components(message)} + if not required.issubset(result[0].covered_components): + raise WireError("insufficient signed component coverage") + body = raw_body(message) + # The maintained HTTP-signature library DOES NOT validate body digests. + # Compare raw bytes before parsing JSON; never reserialize for verification. + if not hmac.compare_digest(message.header("content-digest") or "", _digest(body)): + raise WireError("body digest mismatch") + decoded = json.loads( + body.decode("utf-8"), + object_pairs_hook=_no_duplicate_properties, + parse_constant=_reject_constant, + parse_float=_finite_float, + ) + return _JSON_OBJECT.validate_python(decoded, strict=True) + except (ValueError, TypeError, KeyError, RecursionError, HTTPMessageSignaturesException): + # Never return parser/crypto exceptions, payloads, or configured key material. + raise WireError("HTTP message verification failed") from None + + +def verify_request( + message: SignedMessage, key: ec.EllipticCurvePublicKey, kid: str, now: datetime +) -> dict[str, JsonValue]: + """Authenticate covered request values against the configured key, not a supplied key.""" + return _verify(message, key, kid, now, response=False) + + +def verify_response( + message: SignedMessage, key: ec.EllipticCurvePublicKey, kid: str, now: datetime +) -> dict[str, JsonValue]: + """Authenticate status/body only; independently check expected checkout identifiers.""" + return _verify(message, key, kid, now, response=True) diff --git a/examples/agentic-commerce-accountability/released_ucp/uv-bootstrap.txt b/examples/agentic-commerce-accountability/released_ucp/uv-bootstrap.txt new file mode 100644 index 00000000..91255f0f --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/uv-bootstrap.txt @@ -0,0 +1,6 @@ +# Official PyPI uv 0.12.5 wheels for Linux amd64 and arm64 only. +# https://pypi.org/pypi/uv/0.12.5/json +uv==0.12.5 \ + --hash=sha256:2d65b7b3bc3fd28678f62aa7fb5d90f106ad9782c1354af60b6cecdf9ea9ecd9 \ + --hash=sha256:3e195ccf1ed60c8bb24a6447ce306441a4181d54b602407e09bc56e963911c15 \ + --hash=sha256:58abfb0f658b39a834307a11223bc170294ea214263b4c99ecc7663720d43544 diff --git a/examples/agentic-commerce-accountability/released_ucp/uv.lock b/examples/agentic-commerce-accountability/released_ucp/uv.lock new file mode 100644 index 00000000..7376fc1d --- /dev/null +++ b/examples/agentic-commerce-accountability/released_ucp/uv.lock @@ -0,0 +1,1100 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "agentrust-trace" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "referencing" }, + { name = "rfc3986-validator" }, + { name = "rfc8785" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/f1/ed0c602fbbcbf20a01ce5547ab8bf5cc72c0bcd99fd3baf45e2a29b1af32/agentrust_trace-0.10.0.tar.gz", hash = "sha256:e3ef4438ecb8eb9cbbf1aed13d2d409673ef46afe424c8dc579b8ecce79f2b46", size = 492665, upload-time = "2026-09-06T04:34:52.418Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/99/b7975665d7b9a54b7274f0b4885e488c67be37d289a9b4554ffd71ed6361/agentrust_trace-0.10.0-py3-none-any.whl", hash = "sha256:a82900c426c92f71ec244a13123611953771d232eb02137fec72927225ab472b", size = 64503, upload-time = "2026-09-06T04:34:50.775Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "arrow" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/6d/c6ab91f72f4862d63e446e10248d8f52e8b4c4b7579bb937ddf942a5961e/ast_serialize-0.10.0.tar.gz", hash = "sha256:f47a26cc7d2605fb645b6e7f6c21cf4fb8d7833d00ea8b48e26f057b14eccd01", size = 952608, upload-time = "2026-09-07T10:22:47.595Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/bd/cf2deab2ceb79f4476a30479a2756bb64b63810b7f10bfbd3ce1503d4059/ast_serialize-0.10.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:cbcc3542259ae08153e634d50dc220723415fb24510856523052b5eeb10b4950", size = 1229414, upload-time = "2026-09-07T10:21:49.554Z" }, + { url = "https://files.pythonhosted.org/packages/ee/cb/ec8e84d3ab8073e8536823f7a3cba46e4992d9c8d904481e18509eeea3ff/ast_serialize-0.10.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:2251086be53a375d256faefaedffe5ccd99187be614788f8279b41dae1c5fdee", size = 1210297, upload-time = "2026-09-07T10:21:50.996Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e7/6a1d216160da29566a6125c34e90092a5f9d1444e24e955741113abdf033/ast_serialize-0.10.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1358ef5d98f9dc96468d171317d78d746f9ef8ca45577298512a737ba9d6514", size = 1276064, upload-time = "2026-09-07T10:21:52.452Z" }, + { url = "https://files.pythonhosted.org/packages/96/87/1f1052a0dd00dfe15c129a7073f7b1b5bb632b5507baff27190dd255e0a9/ast_serialize-0.10.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:59ef8acc0ba4315024e5000e5fa8ea656a02e375ebb5ce020f3582d308d6153d", size = 1280982, upload-time = "2026-09-07T10:21:53.992Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8f/9f4578b668b60436249dfa1bc6612283d3cbb665bc6cb2c1e23934d708d8/ast_serialize-0.10.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c9715c4379b33740c94819e55afe59e28d6d3f30f69b903b4fcb2ecb5335531", size = 1552458, upload-time = "2026-09-07T10:21:55.485Z" }, + { url = "https://files.pythonhosted.org/packages/e6/cc/395d96c55e5d91a87fcfa5f15c7d11834cd150d491ce4849b2290334b8e9/ast_serialize-0.10.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26e6dc5d1f7aa642d9e94b98e4d3e1c81dc6b595f01619658f7e9268379826c0", size = 1298414, upload-time = "2026-09-07T10:21:57.01Z" }, + { url = "https://files.pythonhosted.org/packages/7a/8d/473c8fb551af1de55abcc36d591ac18d4175ba7fc15f41d62ad9b30d7385/ast_serialize-0.10.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:760dce78c6c4d4df30d3df9b80be1c521e0ee1d275ae54906e9a95b150692567", size = 1296613, upload-time = "2026-09-07T10:21:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/6f/57/31044aa6fa739634ada7fff321db08a813157663b18629851820fca455a6/ast_serialize-0.10.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:40a7f487e2f5523518270600e0b7f2c61beac3106da142d911c3386ed7abbcfa", size = 1304321, upload-time = "2026-09-07T10:22:00.12Z" }, + { url = "https://files.pythonhosted.org/packages/10/87/c5d7c0c627b991f46fc308a41c0e92b792322440f84e43739b11fb4a1a64/ast_serialize-0.10.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a1c252b746da0ad1883b82502eeff859221eef1d66d3f95dcb2373760a34d440", size = 1350352, upload-time = "2026-09-07T10:22:01.605Z" }, + { url = "https://files.pythonhosted.org/packages/62/ec/d80abe681b7d96b4ea88cf69d559d55a41b1a36c907b579d774caff2527a/ast_serialize-0.10.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:719c7a121cc3022d78b7f4f7a9fd159586e521531c0e495a245375d2c4695d4e", size = 1454522, upload-time = "2026-09-07T10:22:03.149Z" }, + { url = "https://files.pythonhosted.org/packages/8d/7a/d4cc3aa236b9fdce16a394bfd7c12072d9ac30e83f9319aaa14305cbaab9/ast_serialize-0.10.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:d7621157ac99ae00957196e7094fe279e7547d8b505046a9d5509c4fc740e9fc", size = 1555150, upload-time = "2026-09-07T10:22:04.722Z" }, + { url = "https://files.pythonhosted.org/packages/d2/a6/710d1aef678650c75edcb4cdab79998ec63aae54710a438b9fd66c3a616e/ast_serialize-0.10.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:82087a71fe39925ab0068ef2f25444a1c87d1e5f1901f32dcf82f6eddddeac6a", size = 1551843, upload-time = "2026-09-07T10:22:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/2a9c35721a82a506bae980dbdfeaa7b296bf79d818c731b3aaa0ee53c5a7/ast_serialize-0.10.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:8aac958cf0c0b2595a0487c7b5d2ecac88fa5c0221b83b1420ac4bf472f84cb7", size = 1686064, upload-time = "2026-09-07T10:22:07.712Z" }, + { url = "https://files.pythonhosted.org/packages/24/36/f3fd4b6f2c1e4d5eca149e4bfdaa6d93fe77851b21562d14f22a452c71ee/ast_serialize-0.10.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:a53a4d528171b8b709b4e7d89654532189bff53947f24ac4db9c465bb10e7cd2", size = 1478662, upload-time = "2026-09-07T10:22:09.218Z" }, + { url = "https://files.pythonhosted.org/packages/de/65/5135ac9c7e305bd3e6234ef0fbf50bbac2ac00a35076bc167936c6d6d166/ast_serialize-0.10.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:93ae740c6d6f5641573122a7dcffa4baa7ef9144109c54584baad5bf827ad388", size = 1495057, upload-time = "2026-09-07T10:22:10.742Z" }, + { url = "https://files.pythonhosted.org/packages/5a/dd/7dd0e80551dc66fc56197b5439d76fb6173ccf9975df6bf1399e2231069c/ast_serialize-0.10.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:4d10d8fb4b82d5bae455ce9f606920d8dcd563b812b43dcd8d6f9b11089cfb23", size = 1114912, upload-time = "2026-09-07T10:22:12.675Z" }, + { url = "https://files.pythonhosted.org/packages/72/83/a84661b89065fe4986d183abdf0bce5f1a24bea26bbe74054345758357c1/ast_serialize-0.10.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:5917460c0f8322755872db1792788fcaa3835d1fb6a3f129c65ace700c7e4663", size = 1152503, upload-time = "2026-09-07T10:22:14.159Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c0/95bc047bc830f3223d42111ca2980a4328cd09d597773f04f2c689c3330e/ast_serialize-0.10.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:8efb53cf1c439516a563664e014ba9b6b4fd86b4869c180954edce3954a51870", size = 1122527, upload-time = "2026-09-07T10:22:15.656Z" }, + { url = "https://files.pythonhosted.org/packages/70/85/4606f3398e6776c74d44f8387855775c3cf31614b664cdb0e147df752933/ast_serialize-0.10.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:382cb721e2624dd1c2f4f7afd2a9b500930b057d2e24e1078cf6999c5fad1b31", size = 1236237, upload-time = "2026-09-07T10:22:18.767Z" }, + { url = "https://files.pythonhosted.org/packages/09/7b/520b33339f3d5ef9407fb17a00dac0849318158fa0c9853cfe8cc6b7a48d/ast_serialize-0.10.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:56a28b7567ba17f602a17d5603133a1292d307e957b3b5c3f66538e87548aed3", size = 1223110, upload-time = "2026-09-07T10:22:20.243Z" }, + { url = "https://files.pythonhosted.org/packages/a0/af/3c30941ed368ddb54e6ec0edfdd98dc7fdc679c6d4f478f8545c0b8f080a/ast_serialize-0.10.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8919e79a4525cdac63d51cd117c57f324936c902c7ecd0fd09d02aa88323b74d", size = 1285870, upload-time = "2026-09-07T10:22:22.019Z" }, + { url = "https://files.pythonhosted.org/packages/56/81/f0c0bf569388d319aab22c8a7f43747450e26c5ae6a6d9521766157f70f4/ast_serialize-0.10.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1109077d53b377d022d799bc4b171c32c3a711a716248a918d3e07875b5513cd", size = 1290793, upload-time = "2026-09-07T10:22:23.562Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/515a76479ecfc77abe88d7c9954bc3a04f07b30fa33ec836eea80be07fd4/ast_serialize-0.10.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d17c50e1f6e8d950d79c66a18b863dee16cea3106f597203883131dd9c6ca4f", size = 1561250, upload-time = "2026-09-07T10:22:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ba/702af61eb7a6803bf4cd30c09041ab4212e63d553ed9dc2793c95ab340b4/ast_serialize-0.10.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cf2c6cc5a62839869317523e0f74737db0d1759d72fb62cffe57a40e040d6a55", size = 1305696, upload-time = "2026-09-07T10:22:26.612Z" }, + { url = "https://files.pythonhosted.org/packages/31/ce/e6e0a311c16f84f13528f1cb32dec1ce68102711070c10c0c688a6c3cdf5/ast_serialize-0.10.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eb1b20121a62da5937b0c11c8f31667be0102342bf55459bb44a10d8549f8f3f", size = 1302712, upload-time = "2026-09-07T10:22:28.088Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a2/7fd715228a35fec1c587c8156eaabf37b6df98fea48c90eec8f1af362e20/ast_serialize-0.10.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:6e45f7d7a663c28ed44d52b4069e32335edb7a0653a908a43bef2801d8d7c344", size = 1313977, upload-time = "2026-09-07T10:22:29.642Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ff/e1c6a0aefdc80695c2dda0c558d58f90f55cb7bb791545865cd7d4ec39d7/ast_serialize-0.10.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5c75ed7d94660e3ed4fd3c35672ab16c248c0be51d6b845de3edebbb6cf4526c", size = 1360341, upload-time = "2026-09-07T10:22:31.289Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/ab341936f65b663e4909dd5d8772d7c13dd7a39616cf18fd6231197b8177/ast_serialize-0.10.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:45cfbde7e43a56a386b47f13b415d1fa4421b0a5e07ae0c84a7d883ac9aca4b2", size = 1462406, upload-time = "2026-09-07T10:22:32.823Z" }, + { url = "https://files.pythonhosted.org/packages/74/08/1cef69ee4228cd82b9da6b5e29e7fcdd30a6b8b3d6332815781e09666b62/ast_serialize-0.10.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b3a52baa4c457d17a5729aa9bd698da8e61731f1670b4fe33923da54daa480e3", size = 1566792, upload-time = "2026-09-07T10:22:34.381Z" }, + { url = "https://files.pythonhosted.org/packages/d2/01/a3d773d0fe1536485069953d54bece7d8a95aa688e64f5cd692f68949051/ast_serialize-0.10.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:c07cb3f354c234cfacf81da95194b085186d7f4c1f7ad06c7f04fb49e320ef6d", size = 1560873, upload-time = "2026-09-07T10:22:35.844Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ba/7fdda55197f06b2a1613d2ed7e647035b40b26b7aff62ac78e6728128ce9/ast_serialize-0.10.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:8c91b2bc42a12252be53966a1f1b14cc5c843dfca82bee3bce38f2e96b327aa6", size = 1693061, upload-time = "2026-09-07T10:22:37.355Z" }, + { url = "https://files.pythonhosted.org/packages/3a/29/630a7cafd2780651ed73d468518be9d08a6cc3e7c30fdc88c0b2b90c1c5a/ast_serialize-0.10.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:cb698ad6625a55d5d618c9d887423fd04d65dc5ad0f3ae5a8cbd96c46cea2438", size = 1487731, upload-time = "2026-09-07T10:22:39.403Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c3/84fc44b110ee10fb410d49f7f28686c4d23637161b6cb08775c1af8a4507/ast_serialize-0.10.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4496c0d20111a243525cb074a3e9a9313bbd7d3fb1d40ddee2ade9ef680b80c6", size = 1501731, upload-time = "2026-09-07T10:22:40.958Z" }, + { url = "https://files.pythonhosted.org/packages/59/9d/eedfd5a8cbeffd7172281e14d7a7e7640ed3997b3a09382c7756e39a2bbd/ast_serialize-0.10.0-cp39-abi3-win32.whl", hash = "sha256:ca5ff9030b426b245cf7897137872cdb6ebe43b2e4c5e04d5812ed24955a91ba", size = 1121201, upload-time = "2026-09-07T10:22:42.629Z" }, + { url = "https://files.pythonhosted.org/packages/a9/83/2db45120bd0ae66cfcd5e235d4dd29aec564e56440ebb17ec83428bb2685/ast_serialize-0.10.0-cp39-abi3-win_amd64.whl", hash = "sha256:f1a8508054137a1cc67a64915ba588e75f03f546a9b16424aaf0cc9854b0a2a2", size = 1158429, upload-time = "2026-09-07T10:22:44.546Z" }, + { url = "https://files.pythonhosted.org/packages/53/f4/3850ee0050b68f3bd31c06a70cb065f7b129123e5795ff8a0ef642db71da/ast_serialize-0.10.0-cp39-abi3-win_arm64.whl", hash = "sha256:354d12c7463266f2ca655af6f0333ee91ea98c875ffc5f47f10f03c7fe8a0667", size = 1130605, upload-time = "2026-09-07T10:22:46.082Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "bandit" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, +] + +[[package]] +name = "boolean-py" +version = "5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, +] + +[[package]] +name = "cachecontrol" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" }, +] + +[package.optional-dependencies] +filecache = [ + { name = "filelock" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/27/78873dc8b6a56357517b74b6bb9568b80450e7bb4f6ef7e3fa9d22aa0bd7/charset_normalizer-3.5.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:5b6d1386bf0096d26d3a863dc0a487a5b4eb9aa93cf5ba69683d29dde6b9d60f", size = 344456, upload-time = "2026-08-15T08:17:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4c/be49ada26b1f0232d57aa89bbebf997a5cc2332a5616b6eca26ff680044d/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4582c27e8c889d64811987b5967fbd3ae0c823fe1fd933b543d55ac20bb475fa", size = 238530, upload-time = "2026-08-15T08:17:11.563Z" }, + { url = "https://files.pythonhosted.org/packages/76/84/6f1290fa07ae6978d3960caa3eb1b8019bf9284ab7c2297b00c099ef4250/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d1c7a53a6c2103925cdd6d7229f8c567379f211c869793df679f2e9f738c369", size = 230200, upload-time = "2026-08-15T08:17:12.919Z" }, + { url = "https://files.pythonhosted.org/packages/e7/a0/47b18adeed31c8f16ba9700f32c1b18594cfa09f47eb672a488c273c22bf/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e6621fb2a4988d6e53eedc455e5903e2679f3967b8acb3d639f1b63c14a2e893", size = 262222, upload-time = "2026-08-15T08:17:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/38/fe/341861ac118dae06f3ec0eb487488af52128f2ef2faf0b11003944d22259/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c0c10730342b0c9b35dd1d619beb8214e520bd96a1f870f452680b238aab3e0", size = 258951, upload-time = "2026-08-15T08:17:16.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/89/bb5108dc6c3651dca963f2b0a3ba19bbcb370c94e1b6d3e0e844a58e6dca/charset_normalizer-3.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9af956078716df40d985fb0dfeb2c2120c5ca92ba4ff4b388acfd01cdc14d08", size = 248801, upload-time = "2026-08-15T08:17:17.683Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ba/ef83ae3aca816393decfa3530976f38a79812d707b80b580ac33b83f9877/charset_normalizer-3.5.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f9f8405c2c758532c74fed975dbee57be1f31a6e865c031870c79a6ed3212ada", size = 244070, upload-time = "2026-08-15T08:17:19.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0b/c5292a2462d69b7378ea89793bbb5b2b6fcf6f7dd6d1667f9619094ad553/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:96fef3e886d6a9874b14f27fc193fbdc69d5d8035783d86aa4e1cea594e695f9", size = 240110, upload-time = "2026-08-15T08:17:20.547Z" }, + { url = "https://files.pythonhosted.org/packages/46/22/111e5be3b740d5c2a5bfcedb3d237b6591e5c2e82ae9d6ffcb121fe0909c/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5d8531a6569d025f68e2321e7638fb7978f23db58e5f69f56913837aae03816e", size = 232836, upload-time = "2026-08-15T08:17:21.895Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d2/d2aad6fe0dbb44b194bf3becb60f5a0ac48446ade999a47fe7bb41eb09a7/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aae2ee51122d3ae968a3837d97dc24a0aeebb0dea23694422cd172bd30017cd6", size = 262712, upload-time = "2026-08-15T08:17:23.727Z" }, + { url = "https://files.pythonhosted.org/packages/35/5a/337e4663a5eae6de99db940ee8066d4145caafb61327db62deda15313cce/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7235dc28fc6dd9d832ac7c7bce95367dedb85929f17368a0c2bee1e080b9acbf", size = 242977, upload-time = "2026-08-15T08:17:25.157Z" }, + { url = "https://files.pythonhosted.org/packages/ca/85/f82f8a92e31c7519410e2e1afdc630f28ec47490ce2c09a11c1a43cbb459/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4abdc5f9ad448c1ecbfae2974b820535d6bc6e7eef63babbab3d81cf46968c71", size = 260207, upload-time = "2026-08-15T08:17:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/b7/52/643d11ffd60e9ac2fd1fb87e167a19285b9eefeff4a40e63c87cbfbeab36/charset_normalizer-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ba501e667c17d8411f98e67a022d9604ef179aff0e459b7e292c796837c13573", size = 250562, upload-time = "2026-08-15T08:17:27.971Z" }, + { url = "https://files.pythonhosted.org/packages/62/16/46556278c2168d12df9da7fede5dc6fc70e60301b26a82bbeec238c9cfe3/charset_normalizer-3.5.1-cp312-cp312-win32.whl", hash = "sha256:cfa1c0cc3a8f9f53f1243a5a99ac36fd003880199383b37672e86ddda9cb07e2", size = 178507, upload-time = "2026-08-15T08:17:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/9d/7a/4c6c298171e6b3e745633180ff59350fc0ca0db1ffd28df1e369e0579f71/charset_normalizer-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3617ac3cfd8b9888f145ad89dd6e692285834b0201c6074a5eeaad3fd4d668c2", size = 200551, upload-time = "2026-08-15T08:17:30.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d7/eb95a042f0dd22e304b0b6472b154f3546a1a039a9ee89ccb2a7f61591fc/charset_normalizer-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:88e85ab89cb822c1e635f51d6d32e488f94e002e70e2f492bdb8b945543f345a", size = 180700, upload-time = "2026-08-15T08:17:32.028Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/f5/deb1a27aa20746c0278ac998c4179e272004699b2d33959ce020c5ac1615/coverage-7.16.0.tar.gz", hash = "sha256:077f0964087883176ff6ab9b074694cae29f8c708273b13ca62c183c6ed716cd", size = 945620, upload-time = "2026-08-28T21:54:37.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/9c/8d2688694f53dc0b0f0e4783c7eb3c4bb1e79beaf1411879f6dabedf4607/coverage-7.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d1c77c3579ac42798f8b7eed6d3dd258debacca32c8753fc8a1f6eaf1db644f5", size = 223194, upload-time = "2026-08-28T21:51:27.767Z" }, + { url = "https://files.pythonhosted.org/packages/ca/11/f002163dd688aa3fa49ac6a424b7c2705c7fcf80fba18ec9f586d77827ca/coverage-7.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1f81cb1554c3712e41649ed5dc98656b50b958e4da12f0f5adb681ce3db92831", size = 223553, upload-time = "2026-08-28T21:51:29.46Z" }, + { url = "https://files.pythonhosted.org/packages/81/65/f9d469e97c4554372a710650a109004a2434dfc56f577142e5d6057fa0cc/coverage-7.16.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e701938ec9081d3e400a0c9a9a8ae0f7ca44214741daeac4454b1c6ef6dbd19", size = 255054, upload-time = "2026-08-28T21:51:31.54Z" }, + { url = "https://files.pythonhosted.org/packages/95/29/dd89fd39af1a3b6e9a9c3eddeaf03f6376ba517d43d6cbf8b519177e2a10/coverage-7.16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:719a3feb6220dd32ed932d4c3676d17fb8739e2643b29c0e7c3af400ff80ac44", size = 257790, upload-time = "2026-08-28T21:51:33.374Z" }, + { url = "https://files.pythonhosted.org/packages/0a/64/208d26cedc525d6b5db9c492cf9130784c42d9eb08d22badaa7b806005ad/coverage-7.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87771ecf986cff55e87413238cd5e4f54d949c2074bd6fc1657d26a56314ee24", size = 258904, upload-time = "2026-08-28T21:51:35.096Z" }, + { url = "https://files.pythonhosted.org/packages/1f/98/28e2752aa9a8baee5798edade9c95602ca200f4e7eeb503eb64df42e5921/coverage-7.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47d5e1fc0b321c8308a2aacee0497c435b08acaa629b7059798fdf6fc3006352", size = 261165, upload-time = "2026-08-28T21:51:36.744Z" }, + { url = "https://files.pythonhosted.org/packages/eb/77/fa6ae699a0ea2bc12acb38a85d96b786fea0f833c12b5756056350e0e547/coverage-7.16.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:01b18b8a6c9cec8d5f45550e2501426ed982cf2c35016b0acd2ba9b5d8b2fb06", size = 255416, upload-time = "2026-08-28T21:51:38.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/c8/5ee46d1de7d34cb00ba08b5c50da1971114dbc09ca9898ccc32975ec74dd/coverage-7.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:32c56b5b47c50635081445ac404dd08c2d591b9c837c22570aa9e182c3b42cd4", size = 256825, upload-time = "2026-08-28T21:51:40.27Z" }, + { url = "https://files.pythonhosted.org/packages/15/f6/d59e1c0693ad48855fe20169fbf6ee5befefe5887a7fabf5f0bcb464a2dc/coverage-7.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6ad3bbad240ab937512156bc944fdee63ac4dd34a7558a3094548fd4c1150c02", size = 254970, upload-time = "2026-08-28T21:51:43.136Z" }, + { url = "https://files.pythonhosted.org/packages/df/7b/b51bbe05b3a7565927fccfb1be42b8b3c1f4ab15e53d91b303e9923969aa/coverage-7.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4c1f16d5555a195295d0dc9c902612270e3dfed6a11f3bf7bc470b7b6a79ed3c", size = 259039, upload-time = "2026-08-28T21:51:44.983Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/d513f816456a8a43c1859abe88a37d01d7d2515b6c3e24ebb3c9b1dd44ec/coverage-7.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f6c9c21a8bf0d19788f3c5f3e020c90317a0a63ef60521b376003801e21250fb", size = 254539, upload-time = "2026-08-28T21:51:46.733Z" }, + { url = "https://files.pythonhosted.org/packages/dc/54/5542190ceb97e0d1333a4ce0c8f95b2ef2efe790f1ad018a4b61766f849e/coverage-7.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:06f20145a9eb5bf1fd1dde3c0bc2af2e7c22135ab07ca6284d6ada7cc3904c4e", size = 256410, upload-time = "2026-08-28T21:51:48.363Z" }, + { url = "https://files.pythonhosted.org/packages/ee/28/78643f361ff6bb5b2ade90f8bfc8395fe9ca367a18c101f8991215b4c65b/coverage-7.16.0-cp312-cp312-win32.whl", hash = "sha256:916cf8d25c1ce148f7eceb1d45afc9724841200110adc4e53250391852debd91", size = 225239, upload-time = "2026-08-28T21:51:50.22Z" }, + { url = "https://files.pythonhosted.org/packages/67/61/8e76b36c36b1a033dc933dd2480db96b04ce3be975793ce3fad122e7174d/coverage-7.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:78f8b56261d608be102c62edd3a60b66bcd0b581f3f86fdcabaf8b8d95adc950", size = 225775, upload-time = "2026-08-28T21:51:51.912Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f3/bb4787a4b81c1792ca69b502f5f730dbbb609f73fed552ab074c6b92cb8b/coverage-7.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:577c2ac8c0036f6f8edd3a7783a9e67302b17771d1abf0fd2ed246e3158be51b", size = 225159, upload-time = "2026-08-28T21:51:53.667Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5a/234e8fadf85c3cc48cb31c247b9e8e0c7f06ece80f5b29f9b8c241f9da4c/coverage-7.16.0-py3-none-any.whl", hash = "sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73", size = 214977, upload-time = "2026-08-28T21:54:35.189Z" }, +] + +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, +] + +[[package]] +name = "cyclonedx-python-lib" +version = "11.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "license-expression" }, + { name = "packageurl-python" }, + { name = "py-serializable" }, + { name = "sortedcontainers" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/40/6509e6cfd7f2f3255501690f46375fdd224949e21fd1e96f4f4c8a9041b1/cyclonedx_python_lib-11.12.0.tar.gz", hash = "sha256:16767c4039de90c04e9f03348f8f0ed4b8ff842eaa7eefcad3a95685f970dacf", size = 1445378, upload-time = "2026-08-13T07:52:18.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/f0/b2cb999244f2f4194df63ecff6939228178fa63a33be809c412684ca8db7/cyclonedx_python_lib-11.12.0-py3-none-any.whl", hash = "sha256:0e807521a921a5c3cb8ce1153f8a61d29eedfe76a46aac2796b7c6b573391a54", size = 529453, upload-time = "2026-08-13T07:52:16.836Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "detect-secrets" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/67/382a863fff94eae5a0cf05542179169a1c49a4c8784a9480621e2066ca7d/detect_secrets-1.5.0.tar.gz", hash = "sha256:6bb46dcc553c10df51475641bb30fd69d25645cc12339e46c824c1e0c388898a", size = 97351, upload-time = "2024-05-06T17:46:19.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/5e/4f5fe4b89fde1dc3ed0eb51bd4ce4c0bca406246673d370ea2ad0c58d747/detect_secrets-1.5.0-py3-none-any.whl", hash = "sha256:e24e7b9b5a35048c313e983f76c4bd09dad89f045ff059e354f9943bf45aa060", size = 120341, upload-time = "2024-05-06T17:46:16.628Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "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 = "filelock" +version = "3.32.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/a0/50c2c0ce5e74d7721bbb1b19a26ebd339aac5878553a6e35308c2f31f935/filelock-3.32.5.tar.gz", hash = "sha256:f6a6a28f743f9b95ce19db5abe0f376f75eb56517dff21e1a4751e2657d3e83d", size = 222838, upload-time = "2026-08-31T18:56:34.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/d2/b70a31e13d04456d28493f31d2aa087e99eeb2767ef0293b2625727ccb8c/filelock-3.32.5-py3-none-any.whl", hash = "sha256:142cd9fa77a872c5e78c62329a0d15278fadc686eb89e760017968961a4fd6b2", size = 100003, upload-time = "2026-08-31T18:56:33.078Z" }, +] + +[[package]] +name = "fqdn" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/3e/a80a8c077fd798951169626cde3e239adeba7dab75deb3555716415bd9b0/fqdn-1.5.1.tar.gz", hash = "sha256:105ed3677e767fb5ca086a0c1f4bb66ebc3c100be518f0e0d755d9eae164d89f", size = 6015, upload-time = "2021-03-11T07:16:29.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/58/8acf1b3e91c58313ce5cb67df61001fc9dcd21be4fadb76c1a2d540e09ed/fqdn-1.5.1-py3-none-any.whl", hash = "sha256:3a179af3761e4df6eb2e026ff9e1a3033d3587bf980a0b1b2e1e5d08d7358014", size = 9121, upload-time = "2021-03-11T07:16:28.351Z" }, +] + +[[package]] +name = "http-message-signatures" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/80/95/083707b15af3bdbb6c9e54ae7f448046f7ab55f9eaa5cccdc5dd23791d15/http_message_signatures-2.0.1.tar.gz", hash = "sha256:394545b0cc296a4fd45166f57056e7e029dcd5b91d9bf549e108c96c23aa1cba", size = 30517, upload-time = "2026-01-19T04:01:07.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/4a/c778016e3a9c18f283b401319346a980f3ff9556c674afa89a22b9e756bb/http_message_signatures-2.0.1-py3-none-any.whl", hash = "sha256:2b2c463f5d077d3081f27770e8017762d5969a1f17e1dc89e8b96a57c44e8a5e", size = 28204, upload-time = "2026-01-19T04:01:05.738Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isoduration" +version = "20.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "arrow" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/1a/3c8edc664e06e6bd06cce40c6b22da5f1429aa4224d0c590f3be21c91ead/isoduration-20.11.0.tar.gz", hash = "sha256:ac2f9015137935279eac671f94f89eb00584f940f5dc49462a0c4ee692ba1bd9", size = 11649, upload-time = "2020-11-01T11:00:00.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, +] + +[[package]] +name = "joserfc" +version = "1.7.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/94/80fea1514b7c6d7d37804d3fe9ca81455f633347fc98731bd71ffe1faa17/joserfc-1.7.5.tar.gz", hash = "sha256:d5ff536e658e17664f8c1b1ab60dc4aa62aa973fcef1edd33cc44bda45d6f5ea", size = 234990, upload-time = "2026-08-29T13:05:42.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/c5/82addfd375e5ee6520644e0553e4aadde92d668c4fc99cc716d337fe7bb3/joserfc-1.7.5-py3-none-any.whl", hash = "sha256:add2c2c84e8373b084d526a8b53daba5d7a513a118cd2dcd9fc9f979d0922159", size = 71269, upload-time = "2026-08-29T13:05:40.718Z" }, +] + +[[package]] +name = "jsonpointer" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/c7/af399a2e7a67fd18d63c40c5e62d3af4e67b836a2107468b6a5ea24c4304/jsonpointer-3.1.1.tar.gz", hash = "sha256:0b801c7db33a904024f6004d526dcc53bbb8a4a0f4e32bfd10beadf60adf1900", size = 9068, upload-time = "2026-03-23T22:32:32.458Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/6a/a83720e953b1682d2d109d3c2dbb0bc9bf28cc1cbc205be4ef4be5da709d/jsonpointer-3.1.1-py3-none-any.whl", hash = "sha256:8ff8b95779d071ba472cf5bc913028df06031797532f08a7d5b602d8b2a488ca", size = 7659, upload-time = "2026-03-23T22:32:31.568Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[package.optional-dependencies] +format = [ + { name = "fqdn" }, + { name = "idna" }, + { name = "isoduration" }, + { name = "jsonpointer" }, + { name = "rfc3339-validator" }, + { name = "rfc3987" }, + { name = "uri-template" }, + { name = "webcolors" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/39/99c25030e782bdfb7a21be8c05254806a2e4bbb05c8d50c2a2130acbfa05/librt-0.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e87bc679f86a99aa3b26e3c78eeb821a247c9a28eae48eaafcc32c3bf4c3bb9e", size = 151021, upload-time = "2026-08-07T10:47:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/14/43/f4b1bd1b2888798a1409808889a25ea1ba49eaabce7d681ed27734c2df9d/librt-0.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:71599e011ac880e8e45d46047d714871894c7d4ab6f25626f8d4f89da21f368d", size = 155267, upload-time = "2026-08-07T10:47:01.311Z" }, + { url = "https://files.pythonhosted.org/packages/0c/db/3ad9c965c72f1e1d6beeec44ec10a54e17be8ae042fbb4baade16cbadced/librt-0.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c802434092b769b1d613ed2e13fac15fbfce1934a74bd10283b03c0fae231cd1", size = 503136, upload-time = "2026-08-07T10:47:02.45Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/5888a6d76acd62ebce66c61b74d94e9370b9c32929f111e487bb6546f8ed/librt-0.15.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5500eeae393a184d14e1f35645962c27129d20c81afa4069e6ef826ebc2b3aaa", size = 496670, upload-time = "2026-08-07T10:47:03.675Z" }, + { url = "https://files.pythonhosted.org/packages/29/39/ab57cc2f5b276156da02bb7f5a8921bada1cb1993ffec99acf811c602c23/librt-0.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6ecfc32dfb46fb7b565bcd6abf9412acf978775a998273d22888a6d7953730dd", size = 513688, upload-time = "2026-08-07T10:47:04.981Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/bdbb0b648b5c2befb031f4c6f3b1dd857415e8fb492a25a3c764a6681e6c/librt-0.15.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cc46cfd15022e35084355478c9ac809d90b1152222706ac9a7655ec21df6fa", size = 531904, upload-time = "2026-08-07T10:47:06.211Z" }, + { url = "https://files.pythonhosted.org/packages/93/26/473c2e4b6c104e9e58e27ce95fc8005c8bd4fc36cae4f254371125a92db8/librt-0.15.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5f51401d102c885b9ca509e62c79b1dbff286e1b9b047fde6f763780789356d", size = 524427, upload-time = "2026-08-07T10:47:07.592Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/03b3abb82b41714671b907bf6989b228e31e6a8af52dec82b5b0728dc250/librt-0.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cc30523e3f1a23fb7511cc659834a0d01a1042bb9de359bc1c131cc4ec6c9656", size = 543155, upload-time = "2026-08-07T10:47:08.866Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0e/9bb1f0a4affbd0a1888f4f79dc03ed2a299d9a2c26c59ab2a97dcbf11903/librt-0.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:59fe030d8ae4a57e3fb7756bf35a858de74e04066fc8555c53d0af979132af81", size = 546890, upload-time = "2026-08-07T10:47:10.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/84/6937a280d461f7de6e031ffb02edc2b7c3c90d49d630565ce8ff27cbc5f2/librt-0.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5a6526a2a956bbb1e4ae3568c82e650fc99119c66bb011ea60715744955a2b4d", size = 555163, upload-time = "2026-08-07T10:47:11.798Z" }, + { url = "https://files.pythonhosted.org/packages/bc/95/2a2853c1ee014bf102116e7f897a04beeaeb2461b45b79af98bdfb95f1ef/librt-0.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:85ea21ec6730194d67156b0e0b5430ccb1d61f8b8b907e39b37f9812b74a13f0", size = 535812, upload-time = "2026-08-07T10:47:13.279Z" }, + { url = "https://files.pythonhosted.org/packages/c9/4c/cf9601c1b4c5f09280acd5d83abdb2e68527a2be8257136eb42304218622/librt-0.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1e47b8ba865d7ede071a91a7163073bbaeb72541f1ef8a07d512c45c7b5007f2", size = 573688, upload-time = "2026-08-07T10:47:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/47/6d/9ac7cbec46189a7625af4b5acbd25f10d827f4141b2002181848c8418923/librt-0.15.0-cp312-cp312-win32.whl", hash = "sha256:a5207ec414d1c4a2a7231b2086970dc036f94293cdf338190984958a013a42f1", size = 106138, upload-time = "2026-08-07T10:47:15.973Z" }, + { url = "https://files.pythonhosted.org/packages/38/d0/2ae99c83be86ce23f925ac1aeeedc777e97f427c4a8d190c70d0a16e9a87/librt-0.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:73b30cfa976659b3917c8f6153bdb0591c6a9ec6583599fd24a689b690622022", size = 126974, upload-time = "2026-08-07T10:47:17.049Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ef/dd24f9635c730b86b87587967dda7516b1845e8b17684603d31607fed598/librt-0.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:a54cf9e0ef47b96af580849db5471142200568ce1e02cbf416addab551369570", size = 112292, upload-time = "2026-08-07T10:47:18.222Z" }, +] + +[[package]] +name = "license-expression" +version = "30.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boolean-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "msgpack" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/ea2100ec54d30c46ee9dba10a3bfb79b655e96c6df237238a3234c75869b/msgpack-1.2.2.tar.gz", hash = "sha256:9eb0b0e602064527a045ea28c4f174ed69383587e29cebe28947e3b84106eb2a", size = 187025, upload-time = "2026-08-27T10:03:47.793Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/78/90c15bebb1a72667349ca62d4507e9d9369e7f8f76b95f490b823d3622e5/msgpack-1.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a4348705be86e029d04e741cf9ed0dfe03e942d7d3b92e838fa80d3aa2c3ebc", size = 84275, upload-time = "2026-08-27T10:02:07.106Z" }, + { url = "https://files.pythonhosted.org/packages/88/88/c2b6d8e81571da87aa232c0e34a3f3a0e618e6235892065ec82d1d81fc7a/msgpack-1.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a652ceeededf71d3fa40c303a02a149d42338d310162367b91c539d4bd6e0a3", size = 83970, upload-time = "2026-08-27T10:02:08.488Z" }, + { url = "https://files.pythonhosted.org/packages/da/c0/d3ede9f5d16acb4c05a9281859f1e99ef9f877a928eb78454c37f70db001/msgpack-1.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90986cc9aab9d7d1d8f38bcbf65d3f7ac83bdd90c35765db7d691b4829698cba", size = 409401, upload-time = "2026-08-27T10:02:09.877Z" }, + { url = "https://files.pythonhosted.org/packages/41/f0/29f591bea185616cf417645ac03bd3ad9b317483ad8572160e325f7fe777/msgpack-1.2.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77c2e018417dc1d66f235e383877ee885b60ade9d29e494dd581e08af2cb1923", size = 420619, upload-time = "2026-08-27T10:02:11.526Z" }, + { url = "https://files.pythonhosted.org/packages/4e/8e/c70c8c9180c5ddf4440eb8658ebead98e22e7686fbf84f6b165031430750/msgpack-1.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e91332144f69bc3018c91232fac26da580ef748fb8eaddd7914d4458001cc4f", size = 379747, upload-time = "2026-08-27T10:02:13.345Z" }, + { url = "https://files.pythonhosted.org/packages/50/9a/f10ce11fa62700c9ab87a22e65b9ca272f7f673ddd31aeb2de6ae272ad35/msgpack-1.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e915d390d7068b257ca8b62f3fc59fad135c8631d1017ab03b0b924b07c5367", size = 398944, upload-time = "2026-08-27T10:02:15.006Z" }, + { url = "https://files.pythonhosted.org/packages/82/fe/d7be978456ff8552e69a8e270d882e7530e01513c096b293d83df03753ea/msgpack-1.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c522420d78db2431887d45b518e304d86e27b9ad0b30f24e3806a6ad5d8bdbfc", size = 373979, upload-time = "2026-08-27T10:02:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/be/af/91b0d8d3fb3063e259daee3ea8515cea6282f68f4b0e5f0b6fea25762c6e/msgpack-1.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4b554d8164ebb526892194f71dcd96ef1fefe0c250087498785d3ffc04a80be3", size = 417781, upload-time = "2026-08-27T10:02:18.293Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3c/ce8e9efe1fd9e95c78b3705e4300ba7feba3dc6c00fb76259895db155518/msgpack-1.2.2-cp312-cp312-win32.whl", hash = "sha256:0e3315de5a4b2920ccef48d96b4448025e064a10d0f5a250f6584477d839c8d4", size = 65267, upload-time = "2026-08-27T10:02:19.869Z" }, + { url = "https://files.pythonhosted.org/packages/85/98/a33b8b4af14e3476bb0da1b8c36ef7a0f28dcf95db1c5e68ff88cb89d591/msgpack-1.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:b68614fba0570349833b7dd999ff0aed4e5cc8d9eb6e3a7d4527be33c65e33d3", size = 72275, upload-time = "2026-08-27T10:02:21.141Z" }, + { url = "https://files.pythonhosted.org/packages/df/5e/2f323a33a6aba5bd4b2d8b430e4fab21d92cd91c093b49ee287bc166ee54/msgpack-1.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:59d5b93efa45fd09f620d0c9ba81cde339a2c9937af3eea42ee9653094ce6640", size = 65488, upload-time = "2026-08-27T10:02:22.575Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/da/d6effc4f808a842d91edc22535dc9e799d2ff6e91449168b7f47a0771f54/mypy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a32bbbb940af990d3be0b8af321c7b6815bb1b3b48142fe7459b9cc5f58959ff", size = 14047547, upload-time = "2026-08-15T03:02:57.707Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e6/478229701dab76f26485fc8ff5d6f241f393da22447400bbc56f6946aebe/mypy-2.3.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff715e45b2231a8e85de1d163d1b42791e4d7aab8f5145f85fee1b710b735aff", size = 14216515, upload-time = "2026-08-15T03:01:26.496Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fe/7c42327a3b21e84681f691982cbfe43f334a3685f3b683b72c376476c4fa/mypy-2.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:858fc57d3d91fa728e33e7ad71def60fc6272694607b306cd3292db53ae39080", size = 15307789, upload-time = "2026-08-15T03:03:31.62Z" }, + { url = "https://files.pythonhosted.org/packages/59/f4/7e597edbe01b5a56fa958ce541302dcaabfed979966f1dffedbea0ea0fc2/mypy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:851833db876e7b650f93719c74b7879a08e338979c96054fdfc3bfd90a486355", size = 15548831, upload-time = "2026-08-15T03:03:15.55Z" }, + { url = "https://files.pythonhosted.org/packages/a3/52/cb31e084bc0314a1e384bdd677a4b80e55af04ccac077545e2238b9d320a/mypy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:4c5095a327483591c94e0c8d3ef9e50d4ab1369b541eae007c1f23bc2a41f6bb", size = 11226359, upload-time = "2026-08-15T03:03:29.002Z" }, + { url = "https://files.pythonhosted.org/packages/7a/47/88fcf6217b43fa2da81a8c2611370af18141536a4f0294bbf98b457d456d/mypy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfe022634a2a195406bd469e888d2eaf193b02ba7e607391cd7640374aaae3b", size = 10214707, upload-time = "2026-08-15T03:02:48.807Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packageurl-python" +version = "0.17.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pip" +version = "26.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/15/4500e320e6b101ec3b719ae85b697d9940b6cda672bc555bd6016fc60c6f/pip-26.2.1.tar.gz", hash = "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f", size = 1848877, upload-time = "2026-08-04T22:51:14.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl", hash = "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e", size = 1816632, upload-time = "2026-08-04T22:51:12.472Z" }, +] + +[[package]] +name = "pip-api" +version = "0.0.34" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/f1/ee85f8c7e82bccf90a3c7aad22863cc6e20057860a1361083cd2adacb92e/pip_api-0.0.34.tar.gz", hash = "sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625", size = 123017, upload-time = "2024-07-09T20:32:30.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/f7/ebf5003e1065fd00b4cbef53bf0a65c3d3e1b599b676d5383ccb7a8b88ba/pip_api-0.0.34-py3-none-any.whl", hash = "sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb", size = 120369, upload-time = "2024-07-09T20:32:29.099Z" }, +] + +[[package]] +name = "pip-audit" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachecontrol", extra = ["filecache"] }, + { name = "cyclonedx-python-lib" }, + { name = "packaging" }, + { name = "pip-api" }, + { name = "pip-requirements-parser" }, + { name = "platformdirs" }, + { name = "requests" }, + { name = "rich" }, + { name = "tomli" }, + { name = "tomli-w" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a4/f21d5f0a0edabcbce31560b73c7c5a6f72ae87af4236fd1069c8f59a353d/pip_audit-2.10.1.tar.gz", hash = "sha256:1eb4565d19ebe5d48996f4b770b4d2b32887e12cb12cfa637f1a064011b55ffc", size = 54275, upload-time = "2026-06-10T22:17:01.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/a7/b0c504148114047bd1bc9d97447453c6850ca176bb2f3c0038835994e8b7/pip_audit-2.10.1-py3-none-any.whl", hash = "sha256:99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a", size = 62023, upload-time = "2026-06-10T22:17:00.309Z" }, +] + +[[package]] +name = "pip-requirements-parser" +version = "32.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "py-serializable" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "released-ucp-accountability-example" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "agentrust-trace" }, + { name = "cryptography" }, + { name = "http-message-signatures" }, + { name = "joserfc" }, + { name = "jsonschema", extra = ["format"] }, + { name = "pydantic" }, + { name = "requests" }, + { name = "rfc8785" }, + { name = "ucp-sdk" }, +] + +[package.dev-dependencies] +dev = [ + { name = "bandit" }, + { name = "coverage" }, + { name = "detect-secrets" }, + { name = "mypy" }, + { name = "pip-audit" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "agentrust-trace", specifier = "==0.10.0" }, + { name = "cryptography", specifier = "==50.0.1" }, + { name = "http-message-signatures", specifier = "==2.0.1" }, + { name = "joserfc", specifier = "==1.7.5" }, + { name = "jsonschema", extras = ["format"], specifier = "==4.26.0" }, + { name = "pydantic", specifier = "==2.13.5" }, + { name = "requests", specifier = "==2.33.0" }, + { name = "rfc8785", specifier = "==0.1.4" }, + { name = "ucp-sdk", specifier = "==0.5.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = "==1.9.4" }, + { name = "coverage", specifier = "==7.16.0" }, + { name = "detect-secrets", specifier = "==1.5.0" }, + { name = "mypy", specifier = "==2.3.1" }, + { name = "pip-audit", specifier = "==2.10.1" }, + { name = "pytest", specifier = "==9.1.1" }, + { name = "ruff", specifier = "==0.16.5" }, +] + +[[package]] +name = "requests" +version = "2.33.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/64/8860370b167a9721e8956ae116825caff829224fbca0ca6e7bf8ddef8430/requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652", size = 134232, upload-time = "2026-03-25T15:10:41.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, +] + +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" }, +] + +[[package]] +name = "rfc3986-validator" +version = "0.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/88/f270de456dd7d11dcc808abfa291ecdd3f45ff44e3b549ffa01b126464d0/rfc3986_validator-0.1.1.tar.gz", hash = "sha256:3d44bde7921b3b9ec3ae4e3adca370438eccebc676456449b145d533b240d055", size = 6760, upload-time = "2019-10-28T16:00:19.144Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/51/17023c0f8f1869d8806b979a2bffa3f861f26a3f1a66b094288323fba52f/rfc3986_validator-0.1.1-py2.py3-none-any.whl", hash = "sha256:2f235c432ef459970b4306369336b9d5dbdda31b510ca1e327636e01f528bfa9", size = 4242, upload-time = "2019-10-28T16:00:13.976Z" }, +] + +[[package]] +name = "rfc3987" +version = "1.3.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/bb/f1395c4b62f251a1cb503ff884500ebd248eed593f41b469f89caa3547bd/rfc3987-1.3.8.tar.gz", hash = "sha256:d3c4d257a560d544e9826b38bc81db676890c79ab9d7ac92b39c7a253d5ca733", size = 20700, upload-time = "2018-07-29T17:23:47.954Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/d4/f7407c3d15d5ac779c3dd34fbbc6ea2090f77bd7dd12f207ccf881551208/rfc3987-1.3.8-py2.py3-none-any.whl", hash = "sha256:10702b1e51e5658843460b189b185c0366d2cf4cff716f13111b0ea9fd2dce53", size = 13377, upload-time = "2018-07-29T17:23:45.313Z" }, +] + +[[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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, + { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, + { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "stevedore" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/a1/3b8ed9c1fc3aa6eebb57732d924ddaa0500ecc3b638d0454816320994383/stevedore-5.9.1.tar.gz", hash = "sha256:e97a2667923efda926e8713fde6a73616df68210a3cbc6f02b48967b676fd8bf", size = 518111, upload-time = "2026-08-20T15:25:14.754Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/97/bba6e7ec2f5498b9dcb7b1b6400086b80ae5a8ebaff4b25e8c8add75f439/stevedore-5.9.1-py3-none-any.whl", hash = "sha256:5c8ff3a9f336cc1a06ac0f597bc79d11a2f950bfd32e290ca56b5a301fafafbf", size = 54931, upload-time = "2026-08-20T15:25:13.602Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "ucp-sdk" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "email-validator" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/e8/238360ba5b4a14dbab2e0b87cb602523a3ed2929bd7d22bb91f86cad4796/ucp_sdk-0.5.0.tar.gz", hash = "sha256:1a47eedf044240d0b3c6e8453f3e9ed08fd6a3bc920bb56f294ccf13d27fe6e9", size = 106511, upload-time = "2026-08-27T16:10:15.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ec/585701f86224d4113c9e618c0b8045f642ecf108458d93b65d5d8544b46b/ucp_sdk-0.5.0-py3-none-any.whl", hash = "sha256:50acc8b8c45711ce9cf9739613ed8357ba2ba4cefe800d638a2fdf961c42bd04", size = 281364, upload-time = "2026-08-27T16:10:13.927Z" }, +] + +[[package]] +name = "uri-template" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/31/c7/0336f2bd0bcbada6ccef7aaa25e443c118a704f828a0620c6fa0207c1b64/uri-template-1.3.0.tar.gz", hash = "sha256:0e00f8eb65e18c7de20d595a14336e9f337ead580c70934141624b6d1ffdacc7", size = 21678, upload-time = "2023-06-21T01:49:05.374Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/00/3fca040d7cf8a32776d3d81a00c8ee7457e00f80c649f1e4a863c8321ae9/uri_template-1.3.0-py3-none-any.whl", hash = "sha256:a44a133ea12d44a0c0f06d7d42a52d71282e77e2f937d8abd5655b8d56fc1363", size = 11140, upload-time = "2023-06-21T01:49:03.467Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "webcolors" +version = "25.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/7a/eb316761ec35664ea5174709a68bbd3389de60d4a1ebab8808bfc264ed67/webcolors-25.10.0.tar.gz", hash = "sha256:62abae86504f66d0f6364c2a8520de4a0c47b80c03fc3a5f1815fedbef7c19bf", size = 53491, upload-time = "2025-10-31T07:51:03.977Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/cc/e097523dd85c9cf5d354f78310927f1656c422bd7b2613b2db3e3f9a0f2c/webcolors-25.10.0-py3-none-any.whl", hash = "sha256:032c727334856fc0b968f63daa252a1ac93d33db2f5267756623c210e57a4f1d", size = 14905, upload-time = "2025-10-31T07:51:01.778Z" }, +] diff --git a/examples/agentic-commerce-accountability/tests/test_verify_purchase.py b/examples/agentic-commerce-accountability/tests/test_verify_purchase.py new file mode 100644 index 00000000..0566bb04 --- /dev/null +++ b/examples/agentic-commerce-accountability/tests/test_verify_purchase.py @@ -0,0 +1,91 @@ +from __future__ import annotations +import json +import sys +import unittest +from pathlib import Path + +EXAMPLE = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EXAMPLE)) +from generate_fixtures import build # noqa: E402 +from verify_purchase import digest, verify # noqa: E402 + + +def rebind_request(bundle: dict, **changes: object) -> dict: + """Change the request and re-derive every digest that covers it.""" + bundle["purchase_request"].update(changes) + decision = bundle["policy_decision"] + decision["request_digest"] = digest(bundle["purchase_request"]) + evidence = bundle["runtime_evidence"] + evidence["policy_decision_digest"] = digest(decision) + receipt = bundle["purchase_receipt"] + receipt["request_digest"] = digest(bundle["purchase_request"]) + receipt["runtime_evidence_digest"] = digest(evidence) + return bundle + + +class PurchaseVerificationTests(unittest.TestCase): + def test_valid_purchase_is_accepted(self) -> None: + self.assertEqual(verify(build()), []) + + def test_overspend_and_stale_link_are_rejected(self) -> None: + bundle = build() + bundle["purchase_request"]["amount_minor"] = 50_000 + errors = verify(bundle) + self.assertIn("amount exceeds delegated authority", errors) + self.assertIn("policy decision is not bound to the purchase request", errors) + + def test_merchant_substitution_is_rejected(self) -> None: + bundle = build() + bundle["purchase_request"]["merchant_id"] = "merchant:attacker" + self.assertIn("merchant is outside delegated authority", verify(bundle)) + + def test_runtime_must_be_the_delegate(self) -> None: + # Every digest link can hold while the evidence names a runtime the + # grant never delegated to. Rebind the chain so only identity differs. + bundle = build() + evidence = bundle["runtime_evidence"] + evidence["runtime_identity"] = "spiffe://attacker.example/agent/other" + bundle["purchase_receipt"]["runtime_evidence_digest"] = digest(evidence) + self.assertEqual( + verify(bundle), ["runtime is not the delegate named in the authority grant"] + ) + + def test_amount_must_be_a_positive_integer(self) -> None: + for amount in (-50_000, 0, 150.5, True, "100"): + with self.subTest(amount=amount): + bundle = rebind_request(build(), amount_minor=amount) + self.assertEqual( + verify(bundle), ["amount is not a positive integer in minor units"] + ) + + def test_ceiling_must_be_an_integer(self) -> None: + # json.loads accepts NaN, and every integer compares false against it. + for ceiling in (float("nan"), float("inf"), 20_000.0, None): + with self.subTest(ceiling=ceiling): + bundle = build() + grant = bundle["authority_grant"] + grant["max_amount_minor"] = ceiling + bundle["policy_decision"]["authority_digest"] = digest(grant) + rebind_request(bundle) + self.assertEqual( + verify(bundle), ["authority grant ceiling is not an integer"] + ) + + def test_allow_list_must_be_a_list(self) -> None: + # "merchant:hotel-example" in "merchant:hotel-example-annex" is True. + bundle = build() + grant = bundle["authority_grant"] + grant["allowed_merchants"] = "merchant:hotel-example-annex" + bundle["policy_decision"]["authority_digest"] = digest(grant) + rebind_request(bundle) + errors = verify(bundle) + self.assertIn("authority grant allow-lists are not lists", errors) + self.assertIn("merchant is outside delegated authority", errors) + + def test_committed_fixture_matches_generator(self) -> None: + committed = json.loads((EXAMPLE / "fixtures" / "valid-purchase.json").read_text()) + self.assertEqual(committed, build()) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/agentic-commerce-accountability/verify_purchase.py b/examples/agentic-commerce-accountability/verify_purchase.py new file mode 100644 index 00000000..1eb97925 --- /dev/null +++ b/examples/agentic-commerce-accountability/verify_purchase.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Offline verifier for the agentic-commerce accountability example.""" +from __future__ import annotations + +import hashlib +import json +import sys +from pathlib import Path +from typing import Any + + +def digest(value: Any) -> str: + encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode() + return "sha256:" + hashlib.sha256(encoded).hexdigest() + + +def verify(bundle: dict[str, Any]) -> list[str]: + errors: list[str] = [] + grant = bundle["authority_grant"] + request = bundle["purchase_request"] + decision = bundle["policy_decision"] + evidence = bundle["runtime_evidence"] + receipt = bundle["purchase_receipt"] + # A string here would turn membership into a substring test. + operations = grant["allowed_operations"] + merchants = grant["allowed_merchants"] + if not isinstance(operations, list) or not isinstance(merchants, list): + errors.append("authority grant allow-lists are not lists") + operations, merchants = [], [] + if request["operation"] not in operations: + errors.append("operation is outside delegated authority") + if request["currency"] != grant["currency"]: + errors.append("currency differs from delegated authority") + if request["merchant_id"] not in merchants: + errors.append("merchant is outside delegated authority") + amount = request["amount_minor"] + ceiling = grant["max_amount_minor"] + # bool is an int subclass; a negative or zero amount would pass the ceiling, + # and every amount compares false against a NaN ceiling. + if type(amount) is not int or amount <= 0: + errors.append("amount is not a positive integer in minor units") + elif type(ceiling) is not int: + errors.append("authority grant ceiling is not an integer") + elif amount > ceiling: + errors.append("amount exceeds delegated authority") + if evidence["runtime_identity"] != grant["delegate"]: + errors.append("runtime is not the delegate named in the authority grant") + if decision["request_digest"] != digest(request): + errors.append("policy decision is not bound to the purchase request") + if decision["authority_digest"] != digest(grant): + errors.append("policy decision is not bound to the authority grant") + if evidence["policy_decision_digest"] != digest(decision): + errors.append("runtime evidence is not bound to the policy decision") + if receipt["request_digest"] != digest(request): + errors.append("receipt is not bound to the purchase request") + if receipt["runtime_evidence_digest"] != digest(evidence): + errors.append("receipt is not bound to the runtime evidence") + if decision["outcome"] != "allow": + errors.append("policy decision did not allow the purchase") + return errors + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: python verify_purchase.py ", file=sys.stderr) + return 2 + bundle = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + errors = verify(bundle) + if errors: + print("REJECTED") + for error in errors: + print(f"- {error}") + return 1 + print("ACCEPTED: authority, decision, runtime evidence, and receipt are linked") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/ards/README.md b/examples/ards/README.md new file mode 100644 index 00000000..0f08f251 --- /dev/null +++ b/examples/ards/README.md @@ -0,0 +1,69 @@ +# ARDS — Governed Agent Discovery + +This directory shows how agentrust-io.com participates in the [Agentic Resource Discovery Specification](https://github.com/ards-project/ard-spec) (ARDS v0.9) as a **governed-agent federated registry** — a specialized ARD registry that only indexes agents carrying TRACE-v0.2 runtime governance attestations. + +## What's here + +| File | Description | +|---|---| +| `ai-catalog.json` | agentrust-io.com's `/.well-known/ai-catalog.json` — the static catalog that ARD crawlers ingest. Lists cMCP, Agent Manifest SDK, and TRACE Registry as governed MCP servers, plus the agentrust-io.com registry entry for ARD federation. | + +## The integration point + +ARDS `trustManifest.attestations` accepts any attestation type. TRACE-v0.2 is a **runtime governance attestation**: it binds the Cedar policy an agent ran under and a signed tool-call transcript into one independently verifiable artifact. Only a record whose `trace.runtime` carries hardware attestation evidence also shows the run happened in a verified TEE; a `software-only` dev-mode record makes no TEE claim. + +```json +{ + "trustManifest": { + "identity": "spiffe://trust.agentrust-io.com/gateway/cmcp/prod", + "identityType": "spiffe", + "attestations": [ + { "type": "SPIFFE-X509", "uri": "https://agentrust-io.com/.well-known/spiffe/jwks" }, + { + "type": "TRACE-v0.2", + "uri": "https://trace.agentrust-io.com/records/cmcp-prod-latest", + "digest": "sha256:3f4a8b2c..." + } + ] + } +} +``` + +The `uri` resolves to a TRACE Trust Record — an EAT (RFC 9711) signed JSON artifact containing the Cedar policy hash, TEE measurement, and tool transcript hash. The `digest` is the SHA-256 of that record. Both fields allow an ARD registry or orchestrator to verify the governance claim offline, without calling agentrust-io.com. + +## Filtering for governed agents in ARD search + +Any ARD registry that ingests the agentrust-io.com catalog will make this filter work: + +```json +{ + "query": { + "text": "policy-governed MCP gateway with hardware attestation", + "filter": { + "trustManifest.attestations.type": ["TRACE-v0.2"] + } + } +} +``` + +This returns only agents with hardware-verifiable runtime governance records — across any registry in the federation that has ingested TRACE-attested entries. + +## agentrust-io.com as a federated governed-agent registry + +The last entry in `ai-catalog.json` registers the agentrust-io.com registry itself: + +```json +{ + "identifier": "urn:ai:agentrust-io.com:registry:governed-agents", + "type": "application/ai-registry+json", + "url": "https://registry.agentrust-io.com/api/v1/" +} +``` + +ARD registries discovering this entry can route queries with `trustManifest.attestations.type: TRACE-v0.2` to `registry.agentrust-io.com` via federation referrals, delegating governed-agent discovery without replicating the trust logic. + +## TRACE spec + +- Spec: [agentrust-io/trace-spec](https://github.com/agentrust-io/trace-spec) +- ARDS PR: [ards-project/ard-spec#6](https://github.com/ards-project/ard-spec/pull/6) +- AGT ADR: `docs/adr/0032-agt-emits-trace-v01-trust-records.md` in [microsoft/agent-governance-toolkit](https://github.com/microsoft/agent-governance-toolkit) diff --git a/examples/ards/ai-catalog.json b/examples/ards/ai-catalog.json new file mode 100644 index 00000000..e177ccc4 --- /dev/null +++ b/examples/ards/ai-catalog.json @@ -0,0 +1,105 @@ +{ + "$comment": "Reference: agentrust-io.com governed-agent catalog. All entries carry TRACE-v0.2 runtime governance attestations alongside SPIFFE X.509 identity.", + "specVersion": "1.0", + "host": { + "displayName": "agentrust-io.com", + "identifier": "agentrust-io.com", + "documentationUrl": "https://agentrust-io.com/docs", + "logoUrl": "https://agentrust-io.com/assets/logo.svg", + "trustManifest": { + "identity": "spiffe://trust.agentrust-io.com/registry/global", + "identityType": "spiffe", + "attestations": [ + { + "type": "SPIFFE-X509", + "uri": "https://agentrust-io.com/.well-known/spiffe/jwks" + } + ] + } + }, + "entries": [ + { + "identifier": "urn:ai:agentrust-io.com:governed:cmcp-gateway", + "displayName": "Confidential MCP Gateway (cMCP)", + "description": "Hardware-attested policy enforcement gateway for MCP tool calls. Cedar policies evaluated inside AMD SEV-SNP / Intel TDX TEE. Every session produces a TRACE Trust Record independently verifiable offline.", + "type": "application/mcp-server+json", + "url": "https://api.agentrust-io.com/mcp/cmcp", + "version": "0.2.0", + "tags": ["governance", "confidential-computing", "policy-enforcement", "mcp", "tee"], + "capabilities": ["PolicyEnforcement", "HardwareAttestation", "AuditLog", "TRACE"], + "trustManifest": { + "identity": "spiffe://trust.agentrust-io.com/gateway/cmcp/prod", + "identityType": "spiffe", + "attestations": [ + { + "type": "SPIFFE-X509", + "uri": "https://agentrust-io.com/.well-known/spiffe/jwks" + }, + { + "type": "TRACE-v0.2", + "uri": "https://trace.agentrust-io.com/records/cmcp-prod-latest", + "digest": "sha256:3f4a8b2c1d9e7f6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2" + } + ] + } + }, + { + "identifier": "urn:ai:agentrust-io.com:governed:agent-manifest", + "displayName": "Agent Manifest SDK", + "description": "Hardware-anchors all 10 artifacts defining an agent at deployment. Attestation extended to agents — attested at registration and re-attested on any change.", + "type": "application/mcp-server+json", + "url": "https://api.agentrust-io.com/mcp/agent-manifest", + "version": "0.1.0", + "tags": ["governance", "identity", "attestation", "agent-manifest"], + "capabilities": ["AgentIdentity", "HardwareAttestation", "ManifestVerification"], + "trustManifest": { + "identity": "spiffe://trust.agentrust-io.com/service/agent-manifest/prod", + "identityType": "spiffe", + "attestations": [ + { + "type": "SPIFFE-X509", + "uri": "https://agentrust-io.com/.well-known/spiffe/jwks" + }, + { + "type": "TRACE-v0.2", + "uri": "https://trace.agentrust-io.com/records/agent-manifest-prod-latest", + "digest": "sha256:a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + } + ] + } + }, + { + "identifier": "urn:ai:agentrust-io.com:governed:trace-registry", + "displayName": "TRACE Registry", + "description": "Public append-only Merkle registry for TRACE Trust Record anchors. Query attested session records for any TRACE-governed agent. Supports SCITT-anchored verification.", + "type": "application/mcp-server+json", + "url": "https://trace.agentrust-io.com/mcp", + "version": "0.2.0", + "tags": ["governance", "attestation", "trace", "audit", "scitt"], + "capabilities": ["TRACEVerification", "AuditQuery", "SCITTAnchoring"], + "trustManifest": { + "identity": "spiffe://trust.agentrust-io.com/service/trace-registry/prod", + "identityType": "spiffe", + "attestations": [ + { + "type": "SPIFFE-X509", + "uri": "https://agentrust-io.com/.well-known/spiffe/jwks" + }, + { + "type": "TRACE-v0.2", + "uri": "https://trace.agentrust-io.com/records/trace-registry-prod-latest", + "digest": "sha256:f0e9d8c7b6a5f4e3d2c1b0a9f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9" + } + ] + } + }, + { + "identifier": "urn:ai:agentrust-io.com:registry:governed-agents", + "displayName": "agentrust-io.com Governed Agent Registry", + "description": "Federated ARD registry for TRACE-attested, policy-governed agents. All indexed entries carry TRACE-v0.2 runtime governance attestations. Filter with trustManifest.attestations.type=[TRACE-v0.2] to find governed agents across the federation.", + "type": "application/ai-registry+json", + "url": "https://registry.agentrust-io.com/api/v1/", + "tags": ["registry", "governance", "federation", "trace"] + } + ] +} diff --git a/examples/ca2a-delegation/README.md b/examples/ca2a-delegation/README.md new file mode 100644 index 00000000..c21257f2 --- /dev/null +++ b/examples/ca2a-delegation/README.md @@ -0,0 +1,115 @@ +# ca2a-delegation: The Agent-to-Agent Boundary + +The other examples in this repo govern the **agent-to-tool** boundary: cMCP decides what a *single* agent may call. This example governs the **agent-to-agent** boundary with [cA2A](https://github.com/agentrust-io/ca2a): when a lead agent hands part of a task to a sub-agent, each hop carries a signed delegation credential whose scope is a provable subset of its parent, so authority can only ever narrow as it flows outward. + +It uses the credit-risk workflow from [`financial-services/`](../financial-services/README.md) as the worked case, and maps the same pattern onto the other examples at the bottom. + +> **Scope of what runs here.** cA2A is in alpha. This example exercises the part that is built today: **attenuated delegation credentials and offline chain verification** (`ca2a_runtime.delegation`). The live peer path (attesting an inbound peer, sealing the payload to its measurement) and the per-hop TRACE provenance record are cA2A roadmap. See the [cA2A ROADMAP](https://github.com/agentrust-io/ca2a/blob/main/ROADMAP.md) and [LIMITATIONS](https://github.com/agentrust-io/ca2a/blob/main/LIMITATIONS.md). + +--- + +## The scenario + +A credit assessment is decomposed across three agents. The authority to **write the risk report** is the sensitive one: it is granted to the lead agent but is deliberately never delegated onward, so no sub-agent can regain it. + +``` + credit-platform + │ grants: read:documents, screen:sanctions, read:bureau, run:risk-model, write:risk-report + ▼ + lead-credit-agent + │ delegates (evidence-gathering only): read:documents, screen:sanctions, read:bureau + │ WITHHELD: run:risk-model, write:risk-report + ▼ + screening-sub-agent + │ delegates (narrowest): read:bureau + ▼ + bureau-connector +``` + +Each hop's scope is a subset of its parent's; each hop's issuer is the previous hop's subject; each links to its parent by `credential_id`. + +--- + +## Run it + +```bash +git clone https://github.com/agentrust-io/integrations.git +cd integrations/examples/ca2a-delegation +pip install --pre ca2a-runtime # cA2A is alpha; --pre is required +``` + +```bash +python delegation_agent.py +``` + +``` +Building the credit delegation chain: + [0] credit-platform -> lead-credit-agent + scope: ['read:bureau', 'read:documents', 'run:risk-model', 'screen:sanctions', 'write:risk-report'] + [1] lead-credit-agent -> screening-sub-agent + scope: ['read:bureau', 'read:documents', 'screen:sanctions'] + [2] screening-sub-agent -> bureau-connector + scope: ['read:bureau'] + +verify_chain: verified=True hops=3 leaf_scope=['read:bureau'] + authority withheld at the first delegation (never reaches a sub-agent): ['run:risk-model', 'write:risk-report'] + +Now the bureau connector tries to grant itself write:risk-report ... + verify_chain: verified=False code=SCOPE_ESCALATION + reason: hop 2 scope exceeds parent grant +``` + +The demo writes both chains to `chain-output/`. Check either from the CLI. `ca2a verify-chain` in ca2a-runtime 0.2.0 takes no trusted root, so it checks invariants 1 to 4 below but not the root: + +```bash +ca2a verify-chain --chain chain-output/credit-delegation-chain.json +# {"verified": true, "hops": 3, "leaf_scope": ["read:bureau"]} + +ca2a verify-chain --chain chain-output/escalation-attempt.json +# {"verified": false, "code": "SCOPE_ESCALATION", "error": "hop 2 scope exceeds parent grant"} +``` + +--- + +## What verification checks + +`verify_chain` fails on the first violation: + +0. **Trusted root**: the first hop's issuer is in `trusted_root_issuers` (`UNTRUSTED_DELEGATION_ROOT` otherwise). The demo passes the credit platform's key. Leave the argument out and `verify_chain` is structural only: a chain an attacker signs from a key of their own passes 1 to 4. +1. **Signature** on every hop against the issuer's Ed25519 public key. +2. **Continuity**: each hop's issuer is the previous hop's subject. +3. **Attenuation**: each hop's scope is a subset of its parent's scope (`SCOPE_ESCALATION` otherwise). +4. **Anti-replay / structure**: unique `credential_id`s, `parent_id` links to the previous hop, depth increments by one and stays within `max_depth`. + +Because the write authority is withheld at the first delegation, attenuation from a trusted root guarantees that no descendant, however many hops down, can write the risk report. That is separation of duties enforced by the credential, not by convention. + +--- + +## How this maps onto the other examples + +The same agent-to-agent boundary applies wherever one agent hands work to another. cA2A is the layer above the cMCP tool boundary each of these already demonstrates. + +| Example | Delegation hop | Attenuation the chain proves | Escalation it blocks | +|---|---|---|---| +| [`financial-services`](../financial-services/) | lead credit agent → screening sub-agent → bureau connector | evidence-gathering scope is a subset; `write:risk-report` is withheld | a sub-agent trying to write the risk report | +| [`healthcare`](../healthcare/) | clinician agent → pharmacy/medication-safety sub-agent | pharmacist gets only `check:interaction`; no `read:record` or `write:plan` | a consult agent trying to write the treatment plan | +| [`multi-tenant-saas`](../multi-tenant-saas/) | tenant orchestrator → export processor | EU parent holds `export:eea-only`; it cannot mint a child with `export:us` | a cross-border delegation the tenant never had authority to grant | +| [`industrial-embodied-ai`](../industrial-embodied-ai/) | cell orchestrator → robot-cell agent (its `delegation_chain` field is the hook) | the cell agent gets only `move:buffer-zone-1` | a motion request outside the granted zone | + +In each, the sensitive capability is granted high and withheld from the delegated scope, so cA2A's attenuation check is what stops a sub-agent from doing more than it was handed. Where a sub-agent is in a different trust domain (the SaaS export processor, a cross-hospital consult), the cA2A **sealed channel** (roadmap) is what would bind the task payload to that peer's attested measurement. + +--- + +## The tests + +`tests/test_delegation_scenario.py` checks that the chain verifies, that each hop is a subset of its parent, that the write authority is never delegated, and that the escalation attempt raises `ScopeEscalation`. + +```bash +python -m unittest discover -s tests -v +``` + +--- + +## License + +Apache 2.0. See [LICENSE](../LICENSE) in the repo root. diff --git a/examples/ca2a-delegation/chain-output/credit-delegation-chain.json b/examples/ca2a-delegation/chain-output/credit-delegation-chain.json new file mode 100644 index 00000000..e969a72d --- /dev/null +++ b/examples/ca2a-delegation/chain-output/credit-delegation-chain.json @@ -0,0 +1,43 @@ +{ + "chain": [ + { + "credential_id": "credit-cred-0", + "issuer": "7f55fd29bc6632cc46c68910e44d8d0efdb46d6c0599e316ef693ffe07f1cc5b", + "subject": "cb3a37b9f41f452008e0077222a6850f5f8a6bada53b383a85e86c0dee1b7739", + "scope": [ + "read:bureau", + "read:documents", + "run:risk-model", + "screen:sanctions", + "write:risk-report" + ], + "depth": 0, + "parent_id": null, + "signature": "1c67f820a1acabb7821d8310cd85883da567eff2ceaecf678c8bb8dcd8fe06858157a8f40d35f11fd7e4b91ba2a29476bf495fbc2198bcaf9227746e51528500" + }, + { + "credential_id": "credit-cred-1", + "issuer": "cb3a37b9f41f452008e0077222a6850f5f8a6bada53b383a85e86c0dee1b7739", + "subject": "a399d0d49232152829593199ca2c74ff2b66ab669e6e02c1fdeaeb910f0ab1b5", + "scope": [ + "read:bureau", + "read:documents", + "screen:sanctions" + ], + "depth": 1, + "parent_id": "credit-cred-0", + "signature": "62fae79108808b023a95ed68c4ae328de6b4dcd30e11f9ed87cb61fb03fe46f350f2377b9aa2deac55d87da1ec975273b01b8961dc86a48a3b3f7674a2948a0f" + }, + { + "credential_id": "credit-cred-2", + "issuer": "a399d0d49232152829593199ca2c74ff2b66ab669e6e02c1fdeaeb910f0ab1b5", + "subject": "b93a9ce5014c6484d9861d9dc22cab731b0d6d21594c91edea8d11c1be65bfbf", + "scope": [ + "read:bureau" + ], + "depth": 2, + "parent_id": "credit-cred-1", + "signature": "4e8b5ac961f1d174297c726046069f87dac7a6894002ff385fdda5729912783615ae1e5857815377b6289dadde2897ab2f6a56dc8e548e968bc3f923b20dd501" + } + ] +} diff --git a/examples/ca2a-delegation/chain-output/escalation-attempt.json b/examples/ca2a-delegation/chain-output/escalation-attempt.json new file mode 100644 index 00000000..c9e54529 --- /dev/null +++ b/examples/ca2a-delegation/chain-output/escalation-attempt.json @@ -0,0 +1,44 @@ +{ + "chain": [ + { + "credential_id": "credit-cred-0", + "issuer": "a8ea035f0d574cb3ace5c8e988fa10b972935887f266af109b725b909e91d243", + "subject": "e5c7125ecce92f4de683e32e4fa28b9d8ab87fe5a10ceb7de35077a8ab95c435", + "scope": [ + "read:bureau", + "read:documents", + "run:risk-model", + "screen:sanctions", + "write:risk-report" + ], + "depth": 0, + "parent_id": null, + "signature": "6a7bdb7d20800d7e415173449b6a93fddedacbe1e103a6add8e92e52ebd017ae7590aa498721002aea168cd1354f692e38249edb1654ce216f8302295b3ea000" + }, + { + "credential_id": "credit-cred-1", + "issuer": "e5c7125ecce92f4de683e32e4fa28b9d8ab87fe5a10ceb7de35077a8ab95c435", + "subject": "915f9a0266a99eb6c4d92dea2c8a476f53b8ff8ef77fc749dd935a353fc1721b", + "scope": [ + "read:bureau", + "read:documents", + "screen:sanctions" + ], + "depth": 1, + "parent_id": "credit-cred-0", + "signature": "34ee23de76780d88168a7f80abde7b5437aaa029073cd9c37978d7ff205d45c8c7fb12c3409dfeaff601fbbeb1f2ef7da93c087bc6c76cfc36b4aef0a9a88f07" + }, + { + "credential_id": "credit-cred-2", + "issuer": "915f9a0266a99eb6c4d92dea2c8a476f53b8ff8ef77fc749dd935a353fc1721b", + "subject": "a90c204fa79fc6a2f222ad5a9a5581d7cd1f7d47f0e65dba9a269627b275bc9f", + "scope": [ + "read:bureau", + "write:risk-report" + ], + "depth": 2, + "parent_id": "credit-cred-1", + "signature": "7bd69aa6ef7b6ce975b26aa65b15fdcd6c6df7c96a4fefb6ac6e12eea4a9a8d6020586de2e9e706c058906c1be50b9aaae3fb36f616b2ba4509f20664cc76708" + } + ] +} diff --git a/examples/ca2a-delegation/delegation_agent.py b/examples/ca2a-delegation/delegation_agent.py new file mode 100644 index 00000000..152c574a --- /dev/null +++ b/examples/ca2a-delegation/delegation_agent.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +""" +cA2A delegation demo for the credit-risk workflow. + +Builds a three-hop delegation chain (credit platform -> lead credit agent -> +screening sub-agent -> bureau connector), verifies it offline, then shows a +sub-agent trying to widen its scope and being rejected. No network, no TEE. + +Usage: + python delegation_agent.py +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import delegation_scenario as scenario # noqa: E402 +from ca2a_runtime.errors import CA2AError # noqa: E402 + +OUT_DIR = Path(__file__).resolve().parent / "chain-output" + + +def _write(document: dict, name: str) -> Path: + OUT_DIR.mkdir(exist_ok=True) + path = OUT_DIR / name + path.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8") + return path + + +def show_valid_chain() -> None: + chain = scenario.build_credit_chain() + print("Building the credit delegation chain:") + for label, cred in zip(scenario.HOP_LABELS, chain): + print(f" [{cred.depth}] {label}") + print(f" scope: {sorted(cred.scope)}") + print() + + scenario.verify(chain) + leaf = sorted(chain[-1].scope) + withheld = sorted(scenario.CREDIT_CHAIN_SCOPES[0] - chain[1].scope) + print(f"verify_chain: verified=True hops={len(chain)} leaf_scope={leaf}") + print(f" authority withheld at the first delegation (never reaches a sub-agent): {withheld}") + path = _write(scenario.as_chain_document(chain), "credit-delegation-chain.json") + print(f" wrote {path.name}") + print() + + +def show_escalation_attempt() -> None: + chain = scenario.build_escalation_attempt() + print("Now the bureau connector tries to grant itself write:risk-report ...") + path = _write(scenario.as_chain_document(chain), "escalation-attempt.json") + try: + scenario.verify(chain) + print(" UNEXPECTED: the chain verified. This should not happen.") + sys.exit(1) + except CA2AError as exc: + code = getattr(exc, "code", "CA2A_ERROR") + print(f" verify_chain: verified=False code={code}") + print(f" reason: {exc}") + print(f" wrote {path.name}") + print() + + +def main() -> None: + print("=== cA2A: agent-to-agent delegation for the credit-risk workflow ===") + print() + show_valid_chain() + show_escalation_attempt() + print("The write:risk-report authority stays with the lead agent by construction:") + print("it is not in any delegated child's scope, so no descendant can regain it.") + print() + print("Check either chain's structure from the CLI (it takes no trusted root):") + print(" ca2a verify-chain --chain chain-output/credit-delegation-chain.json") + + +if __name__ == "__main__": + main() diff --git a/examples/ca2a-delegation/delegation_scenario.py b/examples/ca2a-delegation/delegation_scenario.py new file mode 100644 index 00000000..6797c10a --- /dev/null +++ b/examples/ca2a-delegation/delegation_scenario.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +cA2A delegation scenario for the credit-risk workflow. + +cMCP governs the agent-to-tool boundary (what one agent may call). cA2A governs +the agent-to-agent boundary: when a lead agent hands part of a task to a +sub-agent, each hop carries a signed delegation credential whose scope is a +provable subset of its parent. This module builds that chain for the credit +scenario and a deliberately invalid variant that tries to widen scope. + +What runs here is the part of cA2A that is built today: attenuated delegation +credentials and offline chain verification (``ca2a_runtime.delegation``). The +live peer path (attesting an inbound peer, sealing the payload to its +measurement) and the per-hop TRACE provenance record are cA2A roadmap; see the +README. +""" + +from __future__ import annotations + +from typing import Any + +from ca2a_runtime.delegation import DelegationCredential, new_keypair, verify_chain + +# Capabilities in the credit workflow. The authority to WRITE the risk report +# is the sensitive one: it is granted to the lead agent at the root but is +# deliberately never delegated onward, so no sub-agent can regain it. +CAP_READ_DOCS = "read:documents" +CAP_SCREEN = "screen:sanctions" +CAP_READ_BUREAU = "read:bureau" +CAP_RUN_MODEL = "run:risk-model" +CAP_WRITE_REPORT = "write:risk-report" + +# depth 0: the credit platform grants the lead agent full assessment authority. +# depth 1: the lead agent delegates evidence-gathering to a screening sub-agent, +# withholding run:risk-model and write:risk-report (separation of duties). +# depth 2: the screening sub-agent delegates only the bureau pull to a connector. +CREDIT_CHAIN_SCOPES = [ + frozenset({CAP_READ_DOCS, CAP_SCREEN, CAP_READ_BUREAU, CAP_RUN_MODEL, CAP_WRITE_REPORT}), + frozenset({CAP_READ_DOCS, CAP_SCREEN, CAP_READ_BUREAU}), + frozenset({CAP_READ_BUREAU}), +] + +# The credit platform's root key. A verifier trusts this issuer, and only this +# issuer, to start a chain. In a deployment it is configured, not generated; +# here it is minted once per process so the demo stays offline. +_PLATFORM_ROOT_PRIV, PLATFORM_ROOT = new_keypair() + +HOP_LABELS = [ + "credit-platform -> lead-credit-agent", + "lead-credit-agent -> screening-sub-agent", + "screening-sub-agent -> bureau-connector", +] + + +def _build(scopes: list[frozenset[str]]) -> list[DelegationCredential]: + """Sign a linear delegation chain over the given per-hop scopes. + + Continuity is preserved: each hop's issuer is the previous hop's subject. + """ + chain: list[DelegationCredential] = [] + priv, pub = _PLATFORM_ROOT_PRIV, PLATFORM_ROOT + parent_id: str | None = None + for depth, scope in enumerate(scopes): + next_priv, next_pub = new_keypair() + cred = DelegationCredential( + credential_id=f"credit-cred-{depth}", + issuer=pub, + subject=next_pub, + scope=scope, + depth=depth, + parent_id=parent_id, + ).sign(priv) + chain.append(cred) + parent_id = cred.credential_id + priv, pub = next_priv, next_pub + return chain + + +def build_credit_chain() -> list[DelegationCredential]: + """A valid, attenuating credit delegation chain (verify_chain passes).""" + return _build(CREDIT_CHAIN_SCOPES) + + +def build_escalation_attempt() -> list[DelegationCredential]: + """An invalid chain: the bureau connector tries to grant itself + write:risk-report, which its parent (the screening sub-agent) never held. + verify_chain raises ScopeEscalation.""" + scopes = list(CREDIT_CHAIN_SCOPES) + scopes[2] = frozenset({CAP_READ_BUREAU, CAP_WRITE_REPORT}) + return _build(scopes) + + +def as_chain_document(chain: list[DelegationCredential]) -> dict[str, Any]: + """Serialize a chain to the {"chain": [...]} shape ca2a verify-chain reads.""" + return {"chain": [c.body() | {"signature": c.signature} for c in chain]} + + +def verify(chain: list[DelegationCredential]) -> None: + """Raise a ca2a_runtime error on the first invariant violation. + + trusted_root_issuers is what makes this an authorization check. Without it + verify_chain is structural only, and a self-consistent chain an attacker + signs from a key of their own passes every other invariant. + """ + verify_chain(chain, trusted_root_issuers={PLATFORM_ROOT}) diff --git a/examples/ca2a-delegation/requirements.txt b/examples/ca2a-delegation/requirements.txt new file mode 100644 index 00000000..e9294bed --- /dev/null +++ b/examples/ca2a-delegation/requirements.txt @@ -0,0 +1,3 @@ +# cA2A is in alpha, so this is a pre-release. Install with: +# pip install --pre ca2a-runtime +ca2a-runtime>=0.2.0 diff --git a/examples/ca2a-delegation/tests/test_delegation_scenario.py b/examples/ca2a-delegation/tests/test_delegation_scenario.py new file mode 100644 index 00000000..c7d0f4ba --- /dev/null +++ b/examples/ca2a-delegation/tests/test_delegation_scenario.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EXAMPLE_DIR)) + +import delegation_scenario as scenario # noqa: E402 +from ca2a_runtime.delegation import DelegationCredential, new_keypair # noqa: E402 +from ca2a_runtime.delegation.credential import ScopeEscalation # noqa: E402 +from ca2a_runtime.errors import UntrustedDelegationRoot # noqa: E402 + + +class DelegationScenarioTests(unittest.TestCase): + def test_valid_chain_verifies(self) -> None: + chain = scenario.build_credit_chain() + scenario.verify(chain) # raises on any violation + self.assertEqual(len(chain), 3) + self.assertEqual(sorted(chain[-1].scope), ["read:bureau"]) + + def test_each_hop_is_a_subset_of_its_parent(self) -> None: + chain = scenario.build_credit_chain() + for parent, child in zip(chain, chain[1:]): + self.assertTrue(child.scope <= parent.scope, f"{child.scope} not subset of {parent.scope}") + + def test_write_authority_is_never_delegated(self) -> None: + chain = scenario.build_credit_chain() + self.assertIn(scenario.CAP_WRITE_REPORT, chain[0].scope) # granted to the lead + for child in chain[1:]: + self.assertNotIn(scenario.CAP_WRITE_REPORT, child.scope) + + def test_continuity_issuer_is_previous_subject(self) -> None: + chain = scenario.build_credit_chain() + for parent, child in zip(chain, chain[1:]): + self.assertEqual(child.issuer, parent.subject) + + def test_escalation_attempt_is_rejected(self) -> None: + chain = scenario.build_escalation_attempt() + with self.assertRaises(ScopeEscalation) as ctx: + scenario.verify(chain) + self.assertEqual(getattr(ctx.exception, "code", None), "SCOPE_ESCALATION") + + def test_chain_from_an_untrusted_root_is_rejected(self) -> None: + # Signatures, continuity and attenuation all hold on a chain an + # attacker mints from a key of their own. Only the root check stops it. + attacker_priv, attacker_pub = new_keypair() + _, agent_pub = new_keypair() + forged = DelegationCredential( + credential_id="credit-cred-0", + issuer=attacker_pub, + subject=agent_pub, + scope=frozenset({scenario.CAP_WRITE_REPORT}), + depth=0, + parent_id=None, + ).sign(attacker_priv) + with self.assertRaises(UntrustedDelegationRoot): + scenario.verify([forged]) + + def test_chain_document_shape(self) -> None: + doc = scenario.as_chain_document(scenario.build_credit_chain()) + self.assertIn("chain", doc) + self.assertEqual(len(doc["chain"]), 3) + for hop in doc["chain"]: + self.assertIn("signature", hop) + self.assertIn("credential_id", hop) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/embodied-action-receipts/README.md b/examples/embodied-action-receipts/README.md new file mode 100644 index 00000000..26b878ee --- /dev/null +++ b/examples/embodied-action-receipts/README.md @@ -0,0 +1,62 @@ +# Embodied Action Receipt Fixtures + +Fixture-style example for offline verification of embodied-action receipts. + +This example complements the cMCP Embodied Action Evidence Profile and the TRACE +`verification.action_receipts` axis. It demonstrates action-level evidence below +a session-level TRACE claim without claiming physical completion, controller +safety, or functional-safety certification. + +Related design threads: + +- cMCP Embodied Action Evidence Profile: agentrust-io/cmcp#339 +- TRACE action-receipt verification axis: agentrust-io/trace-spec#66 + +## What It Shows + +The fixtures cover four verifier outcomes: + +| Fixture | Expected result | What it demonstrates | +|---|---|---| +| `valid-chain.json` | `accepted` | A signed, hash-chained controller receipt sequence binds to the TRACE session, cMCP call id, and action reference. | +| `missing-receipt.json` | `missing` | `verification.action_receipts: required` fails when no action receipt is attached. | +| `signature-mismatch.json` | `invalid` | A receipt with a bad Ed25519 signature is rejected. | +| `controller-rejected.json` | `rejected` | A valid signed receipt can prove controller rejection; rejection is evidence, not a verifier failure. | + +## Boundary + +For embodied AI, `verification.action_receipts: required` means every externally +consequential action has offline-verifiable receipt evidence bound to the +session or cMCP audit `call_id`. + +It does not mean TRACE proves: + +- physical completion; +- controller safety; +- functional-safety certification; +- that the real world changed as intended. + +## Run + +```bash +cd embodied-action-receipts +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +python -m unittest discover -s tests -v +``` + +Verify one fixture manually: + +```bash +python verify_receipts.py fixtures/valid-chain.json +``` + +Regenerate fixtures from the deterministic test key: + +```bash +python generate_fixtures.py +``` + +The deterministic signing seed is public test material. It is only used to make +the committed fixtures reproducible and must never be used in production. diff --git a/examples/embodied-action-receipts/fixtures/controller-rejected.json b/examples/embodied-action-receipts/fixtures/controller-rejected.json new file mode 100644 index 00000000..61d2a47e --- /dev/null +++ b/examples/embodied-action-receipts/fixtures/controller-rejected.json @@ -0,0 +1,41 @@ +{ + "action": { + "action_ref": "sha256:6572d6707269d815b6f59aadeca709e29a5a7d73330265f2b5feb92d0290b21f", + "action_scope": "robot-cell-7/material-bin-a", + "action_timestamp": "2026-06-25T16:30:00Z", + "action_type": "move_material", + "agent_id": "spiffe://factory.example/agent/material-movement/dev" + }, + "case": "controller-rejected", + "expected": { + "receipt_state": "rejected", + "result": "valid" + }, + "note": "A valid rejected receipt is evidence of controller rejection, not physical completion.", + "receipts": [ + { + "action_ref": "sha256:6572d6707269d815b6f59aadeca709e29a5a7d73330265f2b5feb92d0290b21f", + "call_id": "call-material-move-001", + "issuer": "spiffe://factory.example/controller/robot-cell-7", + "issuer_key_id": "robot-cell-7-controller", + "observed_at": "2026-06-25T16:30:02Z", + "prev_receipt_hash": null, + "reason": "human_detected_in_safeguarded_area", + "receipt_id": "receipt-001", + "sequence": 1, + "signature": "ed25519:FvPr51RvrY9iSnUPGlTw_4dRRK8b5c7X8shDFzcaZ6hmiVoFu3m0fsWEjuSsTXi2DNbQ5NutMFZ6z8JHHSbjBQ", + "terminal_state": "controller_rejected", + "trace_id": "trace-session-embodied-001", + "type": "embodied.action_receipt.v0", + "verdict": "rejected" + } + ], + "trace": { + "cmcp_call_id": "call-material-move-001", + "policy_decision": "allow", + "trace_id": "trace-session-embodied-001", + "verification": { + "action_receipts": "required" + } + } +} diff --git a/examples/embodied-action-receipts/fixtures/missing-receipt.json b/examples/embodied-action-receipts/fixtures/missing-receipt.json new file mode 100644 index 00000000..ac3bb089 --- /dev/null +++ b/examples/embodied-action-receipts/fixtures/missing-receipt.json @@ -0,0 +1,23 @@ +{ + "action": { + "action_ref": "sha256:6572d6707269d815b6f59aadeca709e29a5a7d73330265f2b5feb92d0290b21f", + "action_scope": "robot-cell-7/material-bin-a", + "action_timestamp": "2026-06-25T16:30:00Z", + "action_type": "move_material", + "agent_id": "spiffe://factory.example/agent/material-movement/dev" + }, + "case": "missing-receipt", + "expected": { + "receipt_state": "missing", + "result": "invalid" + }, + "receipts": [], + "trace": { + "cmcp_call_id": "call-material-move-001", + "policy_decision": "allow", + "trace_id": "trace-session-embodied-001", + "verification": { + "action_receipts": "required" + } + } +} diff --git a/examples/embodied-action-receipts/fixtures/signature-mismatch.json b/examples/embodied-action-receipts/fixtures/signature-mismatch.json new file mode 100644 index 00000000..2d463d35 --- /dev/null +++ b/examples/embodied-action-receipts/fixtures/signature-mismatch.json @@ -0,0 +1,54 @@ +{ + "action": { + "action_ref": "sha256:6572d6707269d815b6f59aadeca709e29a5a7d73330265f2b5feb92d0290b21f", + "action_scope": "robot-cell-7/material-bin-a", + "action_timestamp": "2026-06-25T16:30:00Z", + "action_type": "move_material", + "agent_id": "spiffe://factory.example/agent/material-movement/dev" + }, + "case": "signature-mismatch", + "expected": { + "receipt_state": "invalid_signature", + "result": "invalid" + }, + "receipts": [ + { + "action_ref": "sha256:6572d6707269d815b6f59aadeca709e29a5a7d73330265f2b5feb92d0290b21f", + "call_id": "call-material-move-001", + "issuer": "spiffe://factory.example/controller/robot-cell-7", + "issuer_key_id": "robot-cell-7-controller", + "observed_at": "2026-06-25T16:30:01Z", + "prev_receipt_hash": null, + "receipt_id": "receipt-001", + "sequence": 1, + "signature": "ed25519:GqkLo5j8ZJzjOGwyv8hzS8vdgaYr_1VAZtdgoNTyjMU0NDuCAXK0oDiC2TTz2raMdXy7KiOoWlr4LgBDzWlGDA", + "terminal_state": "handoff_accepted", + "trace_id": "trace-session-embodied-001", + "type": "embodied.action_receipt.v0", + "verdict": "accepted" + }, + { + "action_ref": "sha256:6572d6707269d815b6f59aadeca709e29a5a7d73330265f2b5feb92d0290b21f", + "call_id": "call-material-move-001", + "issuer": "spiffe://factory.example/controller/robot-cell-7", + "issuer_key_id": "robot-cell-7-controller", + "observed_at": "2026-06-25T16:30:05Z", + "prev_receipt_hash": "sha256:997a9dd7abb8e6a32bab079da314b62bf867605428f7920e91f2a806db1cd338", + "receipt_id": "receipt-002", + "sequence": 2, + "signature": "ed25519:RdJXHXzttRHLqKm6ockpPAU5-Odc3mZuEwzw4wNZRlm-QNPiQTvp2_LJ33TWqquhebJ9PFaq0SS4UEBW3G7bAQ", + "terminal_state": "controller_completed", + "trace_id": "trace-session-embodied-001", + "type": "embodied.action_receipt.v0", + "verdict": "accepted" + } + ], + "trace": { + "cmcp_call_id": "call-material-move-001", + "policy_decision": "allow", + "trace_id": "trace-session-embodied-001", + "verification": { + "action_receipts": "required" + } + } +} diff --git a/examples/embodied-action-receipts/fixtures/valid-chain.json b/examples/embodied-action-receipts/fixtures/valid-chain.json new file mode 100644 index 00000000..0adba677 --- /dev/null +++ b/examples/embodied-action-receipts/fixtures/valid-chain.json @@ -0,0 +1,54 @@ +{ + "action": { + "action_ref": "sha256:6572d6707269d815b6f59aadeca709e29a5a7d73330265f2b5feb92d0290b21f", + "action_scope": "robot-cell-7/material-bin-a", + "action_timestamp": "2026-06-25T16:30:00Z", + "action_type": "move_material", + "agent_id": "spiffe://factory.example/agent/material-movement/dev" + }, + "case": "valid-chain", + "expected": { + "receipt_state": "accepted", + "result": "valid" + }, + "receipts": [ + { + "action_ref": "sha256:6572d6707269d815b6f59aadeca709e29a5a7d73330265f2b5feb92d0290b21f", + "call_id": "call-material-move-001", + "issuer": "spiffe://factory.example/controller/robot-cell-7", + "issuer_key_id": "robot-cell-7-controller", + "observed_at": "2026-06-25T16:30:01Z", + "prev_receipt_hash": null, + "receipt_id": "receipt-001", + "sequence": 1, + "signature": "ed25519:GqkLo5j8ZJzjOGwyv8hzS8vdgaYr_1VAZtdgoNTyjMU0NDuCAXK0oDiC2TTz2raMdXy7KiOoWlr4LgBDzWlGDw", + "terminal_state": "handoff_accepted", + "trace_id": "trace-session-embodied-001", + "type": "embodied.action_receipt.v0", + "verdict": "accepted" + }, + { + "action_ref": "sha256:6572d6707269d815b6f59aadeca709e29a5a7d73330265f2b5feb92d0290b21f", + "call_id": "call-material-move-001", + "issuer": "spiffe://factory.example/controller/robot-cell-7", + "issuer_key_id": "robot-cell-7-controller", + "observed_at": "2026-06-25T16:30:05Z", + "prev_receipt_hash": "sha256:997a9dd7abb8e6a32bab079da314b62bf867605428f7920e91f2a806db1cd338", + "receipt_id": "receipt-002", + "sequence": 2, + "signature": "ed25519:RdJXHXzttRHLqKm6ockpPAU5-Odc3mZuEwzw4wNZRlm-QNPiQTvp2_LJ33TWqquhebJ9PFaq0SS4UEBW3G7bAQ", + "terminal_state": "controller_completed", + "trace_id": "trace-session-embodied-001", + "type": "embodied.action_receipt.v0", + "verdict": "accepted" + } + ], + "trace": { + "cmcp_call_id": "call-material-move-001", + "policy_decision": "allow", + "trace_id": "trace-session-embodied-001", + "verification": { + "action_receipts": "required" + } + } +} diff --git a/examples/embodied-action-receipts/generate_fixtures.py b/examples/embodied-action-receipts/generate_fixtures.py new file mode 100644 index 00000000..1d337469 --- /dev/null +++ b/examples/embodied-action-receipts/generate_fixtures.py @@ -0,0 +1,163 @@ +"""Generate deterministic embodied-action receipt fixtures.""" + +from __future__ import annotations + +import base64 +import copy +import json +from pathlib import Path +from typing import Any + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from verify_receipts import action_preimage, canonical_bytes, receipt_hash, sha256_ref + + +ROOT = Path(__file__).parent +FIXTURES = ROOT / "fixtures" + +SEED = bytes(range(32)) +KEY = Ed25519PrivateKey.from_private_bytes(SEED) +KEY_ID = "robot-cell-7-controller" +ISSUER = "spiffe://factory.example/controller/robot-cell-7" +TRACE_ID = "trace-session-embodied-001" +CALL_ID = "call-material-move-001" + + +def b64url(value: bytes) -> str: + return base64.urlsafe_b64encode(value).decode().rstrip("=") + + +PUBLIC_KEY_B64URL = b64url(KEY.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, +)) + + +def sign(receipt: dict[str, Any]) -> dict[str, Any]: + signed = copy.deepcopy(receipt) + signature = KEY.sign(canonical_bytes(signed)) + signed["signature"] = "ed25519:" + b64url(signature) + return signed + + +def base_trace() -> dict[str, Any]: + return { + "trace_id": TRACE_ID, + "cmcp_call_id": CALL_ID, + "policy_decision": "allow", + "verification": {"action_receipts": "required"}, + } + + +def base_action() -> dict[str, Any]: + action = { + "agent_id": "spiffe://factory.example/agent/material-movement/dev", + "action_type": "move_material", + "action_scope": "robot-cell-7/material-bin-a", + "action_timestamp": "2026-06-25T16:30:00Z", + } + action["action_ref"] = sha256_ref(action_preimage(action)) + return action + + +def receipt(sequence: int, action: dict[str, Any], terminal_state: str, verdict: str, + previous_hash: str | None, observed_at: str) -> dict[str, Any]: + return { + "type": "embodied.action_receipt.v0", + "receipt_id": f"receipt-{sequence:03d}", + "issuer": ISSUER, + "issuer_key_id": KEY_ID, + "trace_id": TRACE_ID, + "call_id": CALL_ID, + "action_ref": action["action_ref"], + "sequence": sequence, + "prev_receipt_hash": previous_hash, + "verdict": verdict, + "terminal_state": terminal_state, + "observed_at": observed_at, + } + + +def valid_chain() -> dict[str, Any]: + action = base_action() + first = sign(receipt( + 1, action, "handoff_accepted", "accepted", None, "2026-06-25T16:30:01Z", + )) + second = sign(receipt( + 2, action, "controller_completed", "accepted", receipt_hash(first), + "2026-06-25T16:30:05Z", + )) + return { + "case": "valid-chain", + "trace": base_trace(), + "action": action, + "receipts": [first, second], + "expected": {"result": "valid", "receipt_state": "accepted"}, + } + + +def missing_receipt() -> dict[str, Any]: + return { + "case": "missing-receipt", + "trace": base_trace(), + "action": base_action(), + "receipts": [], + "expected": {"result": "invalid", "receipt_state": "missing"}, + } + + +def signature_mismatch() -> dict[str, Any]: + fixture = valid_chain() + fixture["case"] = "signature-mismatch" + fixture["receipts"][0]["signature"] = fixture["receipts"][0]["signature"][:-1] + "A" + fixture["expected"] = {"result": "invalid", "receipt_state": "invalid_signature"} + return fixture + + +def controller_rejected() -> dict[str, Any]: + action = base_action() + rejection = sign(receipt( + 1, action, "controller_rejected", "rejected", None, "2026-06-25T16:30:02Z", + )) + rejection["reason"] = "human_detected_in_safeguarded_area" + # The reason is part of the signed receipt, so sign again after adding it. + rejection.pop("signature") + rejection = sign(rejection) + return { + "case": "controller-rejected", + "trace": base_trace(), + "action": action, + "receipts": [rejection], + "expected": {"result": "valid", "receipt_state": "rejected"}, + "note": "A valid rejected receipt is evidence of controller rejection, not physical completion.", + } + + +def write_json(path: Path, value: Any) -> None: + path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") + + +def main() -> None: + FIXTURES.mkdir(exist_ok=True) + write_json(ROOT / "trusted-keys.json", { + "controller_signers": { + KEY_ID: { + "alg": "Ed25519", + "issuer": ISSUER, + "public_key_b64url": PUBLIC_KEY_B64URL, + }, + }, + "note": "Public test verifier key for deterministic fixtures. Never use the signing seed in production.", + }) + write_json(FIXTURES / "valid-chain.json", valid_chain()) + write_json(FIXTURES / "missing-receipt.json", missing_receipt()) + write_json(FIXTURES / "signature-mismatch.json", signature_mismatch()) + write_json(FIXTURES / "controller-rejected.json", controller_rejected()) + print(f"Wrote fixtures to {FIXTURES}") + + +if __name__ == "__main__": + main() + diff --git a/examples/embodied-action-receipts/requirements.txt b/examples/embodied-action-receipts/requirements.txt new file mode 100644 index 00000000..d5baedc6 --- /dev/null +++ b/examples/embodied-action-receipts/requirements.txt @@ -0,0 +1,2 @@ +cryptography>=50.0.1 + diff --git a/examples/embodied-action-receipts/tests/test_verify_receipts.py b/examples/embodied-action-receipts/tests/test_verify_receipts.py new file mode 100644 index 00000000..2dc84689 --- /dev/null +++ b/examples/embodied-action-receipts/tests/test_verify_receipts.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import json +import sys +import tempfile +import unittest +from copy import deepcopy +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from generate_fixtures import sign +from verify_receipts import ( + load_trusted_keys, + receipt_hash, + verify_fixture, +) + + +class ReceiptFixtureTests(unittest.TestCase): + def test_fixtures_match_expected_results(self) -> None: + for path in sorted((ROOT / "fixtures").glob("*.json")): + with self.subTest(path=path.name): + fixture = json.loads(path.read_text()) + self.assertEqual(verify_fixture(path), fixture["expected"]) + + def test_rejected_receipt_is_valid_evidence(self) -> None: + result = verify_fixture(ROOT / "fixtures" / "controller-rejected.json") + self.assertEqual(result, {"result": "valid", "receipt_state": "rejected"}) + + def test_explicit_empty_trust_store_trusts_nobody(self) -> None: + result = verify_fixture(ROOT / "fixtures" / "valid-chain.json", trusted_keys={}) + self.assertEqual(result, {"result": "invalid", "receipt_state": "untrusted"}) + + def test_trusted_key_cannot_claim_a_different_issuer(self) -> None: + fixture = json.loads((ROOT / "fixtures" / "valid-chain.json").read_text()) + forged = deepcopy(fixture["receipts"][0]) + forged.pop("signature") + forged["issuer"] = "spiffe://attacker.example/controller" + fixture["receipts"] = [sign(forged)] + path = ROOT / "fixtures" / ".test-wrong-issuer.json" + try: + path.write_text(json.dumps(fixture)) + result = verify_fixture(path, trusted_keys=load_trusted_keys()) + finally: + path.unlink(missing_ok=True) + self.assertEqual(result, {"result": "invalid", "receipt_state": "issuer_mismatch"}) + + def test_receipt_sequences_must_be_contiguous(self) -> None: + fixture = json.loads((ROOT / "fixtures" / "valid-chain.json").read_text()) + first = fixture["receipts"][0] + second = deepcopy(fixture["receipts"][1]) + second.pop("signature") + second["sequence"] = 3 + second["prev_receipt_hash"] = receipt_hash(first) + fixture["receipts"][1] = sign(second) + path = ROOT / "fixtures" / ".test-sequence-gap.json" + try: + path.write_text(json.dumps(fixture)) + result = verify_fixture(path) + finally: + path.unlink(missing_ok=True) + self.assertEqual(result, {"result": "invalid", "receipt_state": "sequence_mismatch"}) + + def verify_mutated(self, mutate) -> dict: + fixture = json.loads((ROOT / "fixtures" / "valid-chain.json").read_text()) + mutate(fixture) + with tempfile.TemporaryDirectory() as tmp: + path = Path(tmp) / "fixture.json" + path.write_text(json.dumps(fixture)) + return verify_fixture(path) + + def test_signature_encoding_is_not_malleable(self) -> None: + # A lenient base64 decoder drops characters outside the alphabet and + # ignores surplus padding, so one signature had many accepted spellings. + def junk(fixture: dict) -> None: + sig = fixture["receipts"][-1]["signature"] + fixture["receipts"][-1]["signature"] = sig[:12] + "!!!!" + sig[12:] + + def padded(fixture: dict) -> None: + fixture["receipts"][-1]["signature"] += "====" + + for mutate in (junk, padded): + with self.subTest(mutation=mutate.__name__): + self.assertEqual( + self.verify_mutated(mutate), + {"result": "invalid", "receipt_state": "signature_format"}, + ) + + def test_malformed_fixture_is_invalid_not_a_crash(self) -> None: + mutations = { + "receipts_not_a_list": lambda f: f.__setitem__("receipts", "x"), + "missing_sequence": lambda f: f["receipts"][0].pop("sequence"), + "signature_not_a_string": lambda f: f["receipts"][0].__setitem__("signature", 5), + "mixed_sequence_types": lambda f: f["receipts"][0].__setitem__("sequence", "1"), + "action_not_an_object": lambda f: f.__setitem__("action", []), + "receipt_not_an_object": lambda f: f["receipts"].append(7), + } + for name, mutate in mutations.items(): + with self.subTest(mutation=name): + self.assertEqual( + self.verify_mutated(mutate), + {"result": "invalid", "receipt_state": "malformed"}, + ) + + def test_truncated_signature_is_a_format_error(self) -> None: + def truncate(fixture: dict) -> None: + fixture["receipts"][0]["signature"] = "ed25519:" + "A" * 5 + + self.assertEqual( + self.verify_mutated(truncate), + {"result": "invalid", "receipt_state": "signature_format"}, + ) + + +if __name__ == "__main__": + unittest.main() + diff --git a/examples/embodied-action-receipts/trusted-keys.json b/examples/embodied-action-receipts/trusted-keys.json new file mode 100644 index 00000000..989a3b6e --- /dev/null +++ b/examples/embodied-action-receipts/trusted-keys.json @@ -0,0 +1,10 @@ +{ + "controller_signers": { + "robot-cell-7-controller": { + "alg": "Ed25519", + "issuer": "spiffe://factory.example/controller/robot-cell-7", + "public_key_b64url": "A6EHv_POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg" + } + }, + "note": "Public test verifier key for deterministic fixtures. Never use the signing seed in production." +} diff --git a/examples/embodied-action-receipts/verify_receipts.py b/examples/embodied-action-receipts/verify_receipts.py new file mode 100644 index 00000000..0ced0e8d --- /dev/null +++ b/examples/embodied-action-receipts/verify_receipts.py @@ -0,0 +1,177 @@ +"""Offline verifier for embodied-action receipt fixtures.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +ROOT = Path(__file__).parent +MALFORMED = {"result": "invalid", "receipt_state": "malformed"} +_B64URL = re.compile(r"[A-Za-z0-9_-]*") + + +@dataclass(frozen=True) +class TrustedSigner: + issuer: str + public_key: Ed25519PublicKey + + +def canonical_bytes(value: Any) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + + +def b64url_decode(value: str) -> bytes: + """Strict unpadded base64url: one accepted spelling per byte string.""" + if not isinstance(value, str) or _B64URL.fullmatch(value) is None: + raise ValueError("not unpadded base64url") + decoded = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + if base64.urlsafe_b64encode(decoded).rstrip(b"=").decode() != value: + raise ValueError("non-canonical base64url") + return decoded + + +def sha256_ref(value: Any) -> str: + return "sha256:" + hashlib.sha256(canonical_bytes(value)).hexdigest() + + +def action_preimage(action: dict[str, Any]) -> dict[str, Any]: + return { + "agent_id": action["agent_id"], + "action_type": action["action_type"], + "action_scope": action["action_scope"], + "action_timestamp": action["action_timestamp"], + } + + +def receipt_preimage(receipt: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in receipt.items() if k != "signature"} + + +def receipt_hash(receipt: dict[str, Any]) -> str: + return sha256_ref(receipt) + + +def load_trusted_keys(path: Path = ROOT / "trusted-keys.json") -> dict[str, TrustedSigner]: + data = json.loads(path.read_text()) + keys = {} + for key_id, key in data["controller_signers"].items(): + keys[key_id] = TrustedSigner( + issuer=key["issuer"], + public_key=Ed25519PublicKey.from_public_bytes( + b64url_decode(key["public_key_b64url"]) + ), + ) + return keys + + +def verify_fixture( + path: Path, trusted_keys: dict[str, TrustedSigner] | None = None +) -> dict[str, Any]: + if trusted_keys is None: + trusted_keys = load_trusted_keys() + return verify_text(path.read_text(), trusted_keys) + + +def verify_text(text: str, trusted_keys: dict[str, TrustedSigner]) -> dict[str, Any]: + """Verify fixture JSON. Always returns a verdict; never raises on bad input.""" + try: + return verify_document(json.loads(text), trusted_keys) + except (KeyError, TypeError, AttributeError, ValueError, RecursionError): + # The fixture is untrusted input: a wrong shape is a verdict, not a crash. + return dict(MALFORMED) + + +def verify_document( + fixture: dict[str, Any], trusted_keys: dict[str, TrustedSigner] +) -> dict[str, Any]: + trace = fixture["trace"] + action = fixture["action"] + receipts = fixture.get("receipts", []) + expected_required = trace.get("verification", {}).get("action_receipts") == "required" + + recomputed_action_ref = sha256_ref(action_preimage(action)) + if recomputed_action_ref != action.get("action_ref"): + return {"result": "invalid", "receipt_state": "action_ref_mismatch"} + + if expected_required and not receipts: + return {"result": "invalid", "receipt_state": "missing"} + + previous_hash = None + final_state = "absent" + final_verdict = None + + for expected_sequence, receipt in enumerate( + sorted(receipts, key=lambda r: r["sequence"]), start=1 + ): + if receipt["sequence"] != expected_sequence: + return {"result": "invalid", "receipt_state": "sequence_mismatch"} + if receipt["call_id"] != trace["cmcp_call_id"]: + return {"result": "invalid", "receipt_state": "call_id_mismatch"} + if receipt["trace_id"] != trace["trace_id"]: + return {"result": "invalid", "receipt_state": "trace_id_mismatch"} + if receipt["action_ref"] != action["action_ref"]: + return {"result": "invalid", "receipt_state": "action_ref_mismatch"} + if receipt.get("prev_receipt_hash") != previous_hash: + return {"result": "invalid", "receipt_state": "chain_mismatch"} + + signer = trusted_keys.get(receipt["issuer_key_id"]) + if signer is None: + return {"result": "invalid", "receipt_state": "untrusted"} + if receipt.get("issuer") != signer.issuer: + return {"result": "invalid", "receipt_state": "issuer_mismatch"} + + signature = receipt["signature"] + if not signature.startswith("ed25519:"): + return {"result": "invalid", "receipt_state": "signature_format"} + + try: + signature_bytes = b64url_decode(signature.removeprefix("ed25519:")) + except ValueError: + return {"result": "invalid", "receipt_state": "signature_format"} + try: + signer.public_key.verify( + signature_bytes, + canonical_bytes(receipt_preimage(receipt)), + ) + except InvalidSignature: + return {"result": "invalid", "receipt_state": "invalid_signature"} + + previous_hash = receipt_hash(receipt) + final_verdict = receipt["verdict"] + final_state = receipt["terminal_state"] + + if final_verdict == "rejected" or final_state.endswith("rejected"): + return {"result": "valid", "receipt_state": "rejected"} + if final_verdict == "accepted": + return {"result": "valid", "receipt_state": "accepted"} + return {"result": "valid", "receipt_state": final_state} + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: python verify_receipts.py ", file=sys.stderr) + return 2 + + path = Path(sys.argv[1]) + result = verify_fixture(path) + print(json.dumps(result, indent=2, sort_keys=True)) + + expected = json.loads(path.read_text()).get("expected") + if expected and result != expected: + print(f"expected {expected}, got {result}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/examples/financial-services/README.md b/examples/financial-services/README.md new file mode 100644 index 00000000..7fd3b784 --- /dev/null +++ b/examples/financial-services/README.md @@ -0,0 +1,218 @@ +# financial-services: EU Corporate Credit Risk Agent Demo + +End-to-end demo of a corporate credit risk agent running a six-step assessment through a cMCP Runtime with Cedar policy enforcement and signed TRACE Trust Records for EU regulatory compliance (EU AI Act, CRR, EBA loan origination, EU AML, IFRS 9, DORA). + +The obligor is a fictional German Mittelstand manufacturer. Identifiers are valid in format (LEI check digits per ISO 17442 / ISO 7064 MOD 97-10, German IBANs likewise), the credit bureau is Creditreform on its Bonitätsindex scale, and the guardrails act on the real output of the assessment rather than on a single hard-coded number. + +--- + +## What the demo shows + +**1. Cryptographic proof of which tools an AI agent called** +The cMCP Runtime intercepts every MCP tool call, records it in a hash-chained audit log persisted to SQLite, and seals the session into a signed `RuntimeClaim` (the TRACE Trust Record). An auditor can verify after the fact exactly which tools ran, in what order, and what was denied, without trusting the agent process. + +**2. Cedar policy as machine-readable compliance** +The rules in `policy/allow.cedar` encode the bank's controls directly. Each forbid returns its `@annotation` metadata as structured advice on deny, so the calling system knows *why* the write was blocked and *who must review*. + +**3. Controls that fire on the assessment result, not the request** +The agent screens the client, pulls a bureau report, aggregates group exposure and runs the PD/LGD model, then passes the outcome of those steps (CDD status, IFRS 9 stage, concentration breach, facility amount) into the write call. The Cedar guardrails act on those values, so the deny reflects the actual credit decision. + +**4. Attestation-gated data access (DORA Art. 9)** +A Cedar rule forbids confidential (`mnpi`) tools when the runtime reports no attestation platform at all (`attestation_platform == "unknown"`). A software-only dev-mode runtime reports `software-only` and passes this gate, which is how the demo runs without a TEE. To require hardware attestation for `mnpi` data, forbid unless `attestation_platform` is one of the hardware platforms you accept. + +--- + +## The workflow + +``` + +------------------------------------------------------------------+ + | Credit Risk Agent (agent/credit_risk_agent.py) | + +-------------------------------+----------------------------------+ + | tools/call (MCP, JSON-RPC 2.0) + v + +------------------------------------------------------------------+ + | cMCP Runtime :8443 | + | Cedar engine (allow.cedar) | catalog checker | audit + signer | + +-------------------------------+----------------------------------+ + | proxied tool call + v + +------------------------------------------------------------------+ + | Mock EU Credit Risk MCP Server :8080 | + | 1. finance.document_reader annual financial statements | + | 2. finance.sanctions_screening entity + UBO CDD/AML screen | + | 3. finance.credit_bureau_lookup Creditreform Bonitätsindex | + | 4. finance.exposure_aggregation group exposure vs limit | + | 5. finance.risk_model PD/LGD/EAD, rating, IFRS 9 | + | 6. finance.risk_report_writer write to core banking | + +------------------------------------------------------------------+ +``` + +Tool responses are computed by `credit_engine.py` from a small set of client fixtures, so the server, the tests (`tests/test_credit_engine.py`) and the agent all agree on the same data. + +--- + +## The catalog + +`catalog.json` registers the six tools with approved definitions, classifications, and definition hashes (`sha256` of the canonical JSON of `approved_definition`; the runtime rejects drifted definitions). + +| Tool | compliance_domain | sensitivity_level | +|---|---|---| +| `finance.document_reader` | mnpi | confidential | +| `finance.sanctions_screening` | pii | confidential | +| `finance.credit_bureau_lookup` | pii | confidential | +| `finance.exposure_aggregation` | internal | confidential | +| `finance.risk_model` | internal | confidential | +| `finance.risk_report_writer` | internal | confidential | + +--- + +## Run it + +```bash +git clone https://github.com/agentrust-io/integrations.git +cd integrations/examples +pip install cmcp-runtime httpx +``` + +**Terminal 1 - mock MCP server:** + +```bash +cd financial-services +python server/mock_mcp_server.py +``` + +**Terminal 2 - runtime** (run from inside `financial-services/` - config paths resolve relative to the working directory): + +```bash +cd financial-services +CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml +``` + +**Terminal 3 - the three scenarios:** + +```bash +# A. clean SME, EUR 250k, within limits, performing -> all six steps allow +python agent/credit_risk_agent.py --scenario clean + +# B. EUR 750k, exceeds delegated authority and breaches the concentration limit +python agent/credit_risk_agent.py --scenario large-exposure + +# C. a beneficial owner matches a sanctions list -> CDD does not clear +python agent/credit_risk_agent.py --scenario sanctions-hit +``` + +### Scenario A - clean (`clean`) + +Rheintal Präzisionstechnik GmbH, EUR 250,000. CDD clears, Creditreform index 178 (very good), aggregate exposure within the limit, IFRS 9 stage 1. All six steps allow and the report is written. + +### Scenario B - large exposure (`large-exposure`) + +Nordwind Logistik AG, EUR 750,000. The obligor is strong, but the facility exceeds the EUR 500,000 delegated lending authority *and* pushes aggregate group exposure past the single-obligor concentration limit. The write is denied: + +``` +[6/6] finance.risk_report_writer ... + -> decision: deny (POLICY_DENY) + advice from policy: + id: delegated-authority-hitl + reason: human-review-required + regulation: eba-gl-2020-06 + delegated_authority_limit_eur: 500000 +``` + +### Scenario C - sanctions hit (`sanctions-hit`) + +Meridian Trading DMCC, EUR 200,000. Sanctions screening returns a beneficial-owner match, so `cdd_cleared` stays false. Even though the agent proceeds to the write, the runtime blocks it, so the CDD gate is enforced by the runtime rather than left to the agent's good behaviour: + +``` +[6/6] finance.risk_report_writer ... + -> decision: deny (POLICY_DENY) + advice from policy: + id: cdd-clearance-required + reason: cdd-clearance-required + regulation: eu-aml-regulation-2024-1624 +``` + +--- + +## The Cedar policy + +`policy/allow.cedar` has no catch-all permit: each tool is explicitly permitted only for the `credit-risk-analyst` workflow (declared by the agent via `_cmcp.workflow_id`). On top of the workflow-scoped permits, four guardrails forbid writing the assessment when a control fails: + +| `@id` | Fires when | Regulation | +|---|---|---| +| `cdd-clearance-required` | `cdd_cleared` is not true (sanctions/PEP hit) | EU AML Regulation (EU) 2024/1624 | +| `delegated-authority-hitl` | `amount_eur > 500000` | EBA/GL/2020/06 (loan origination) | +| `large-exposure-concentration` | `breaches_concentration_limit` is true | CRR Art. 395 (large exposures) | +| `credit-impaired-manual-review` | `ifrs9_stage == 3` | IFRS 9 | +| `require-attested-runtime` | `mnpi` tool with no attestation evidence | DORA Art. 9 | + +Action names follow the cMCP convention: `finance.risk_report_writer` becomes `Action::"Finance.riskReportWriter"` (the segment before the dot PascalCase, each underscore segment after it camelCase). Tool arguments are available under `context.arguments`; the `@annotation` values are returned in the deny response's `error.data.advice`. + +> On the concentration limit: the CRR Art. 395 regulatory ceiling is 25% of Tier 1 capital, far larger than any single facility here. Banks implement it through a tighter internal single-obligor risk-appetite limit (EUR 2,000,000 in this demo); the runtime enforces the internal limit and cites CRR Art. 395 as the framework it implements. + +--- + +## The TRACE Trust Records + +`trace-output/` holds one signed record per scenario, captured from real runs: + +| File | Result | +|---|---| +| `clean-trust-record.json` | 6 calls, 6 allowed, 0 denied | +| `large-exposure-trust-record.json` | 6 calls, 5 allowed, 1 denied | +| `sanctions-hit-trust-record.json` | 6 calls, 5 allowed, 1 denied | + +Verify one (schema, signature, audit chain, and hashes pass; hardware attestation fails in software-only dev mode): + +```bash +cmcp verify trace-output/large-exposure-trust-record.json +``` + +| TRACE field | EU AI Act | CRR / EBA | DORA | GDPR | +|---|---|---|---|---| +| `trace.policy.bundle_hash` | Art. 9 - risk mgmt system version | CRR Art. 395 controls | Art. 9 - change management | - | +| `trace.policy.version` | Art. 12 - log versioning | EBA/GL/2020/06 governance | Art. 11 - ICT change log | - | +| `trace.runtime` + `signature` | Art. 12 - tamper-evident logging | - | Art. 9 - security of ICT systems | Art. 32 - security of processing | +| `gateway.call_summary` | Art. 12 - post-hoc review data | EBA/GL/2020/06 basis of decision | Art. 17 - incident management | Art. 5(1)(c) - data minimisation | +| `trace.data_class` | Art. 10 - data governance | - | - | Art. 5(1)(b) - purpose limitation | + +Export the full audit chain for a closed session: + +```bash +curl "http://localhost:8443/audit/export?session_id=" | python3 -m json.tool +``` + +--- + +## The tests + +`tests/test_credit_engine.py` exercises the domain logic without a running server: LEI check digits, CDD clear vs hit, the Creditreform scale, exposure aggregation on both sides of the limit, and IFRS 9 staging. + +```bash +python -m unittest discover -s tests -v +``` + +--- + +## Extending this example + +- **Real MCP server:** point `server.url` in `catalog.json` at your endpoint; get the TLS fingerprint with `openssl s_client -connect host:443 | openssl x509 -fingerprint -sha256 -noout`. +- **Change a threshold:** edit the `when` clause and the matching `@annotation` together (e.g. the delegated-authority limit). +- **Hardware attestation:** drop `CMCP_DEV_MODE=1` on a VM with TPM 2.0 / SEV-SNP / TDX. +- **Production hardening:** set `CMCP_BEARER_TOKEN`, `CMCP_POLICY_HASH`, `CMCP_CATALOG_HASH`. + +--- + +## Troubleshooting + +**Tool call returns 502 `UPSTREAM_UNAVAILABLE`** - the mock MCP server is not running on port 8080 (Terminal 1). + +**Runtime exits at startup** - config paths resolve relative to the working directory; run `cmcp start` from inside `financial-services/`. + +**Cedar policy parse error** - check for stray Unicode (smart quotes) in `allow.cedar`; annotations must be `@key("value")` with double quotes. + +--- + +## License + +Apache 2.0. See [LICENSE](../LICENSE) in the repo root. diff --git a/examples/financial-services/agent/credit_risk_agent.py b/examples/financial-services/agent/credit_risk_agent.py new file mode 100644 index 00000000..64aa033d --- /dev/null +++ b/examples/financial-services/agent/credit_risk_agent.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +""" +Credit risk agent demo for an EU corporate lender. + +Runs a six-step credit assessment through the cMCP Runtime using JSON-RPC 2.0 +over HTTP, then closes the session to obtain the signed TRACE Trust Record. The +arguments the agent passes to the final write call (CDD clearance, IFRS 9 stage, +concentration breach, rating) are derived from the earlier tool outputs, so the +Cedar guardrails act on the real result of the assessment. + +Three scenarios: + + --scenario clean Rheintal Präzisionstechnik GmbH, EUR 250k. + Clean CDD, within limits, performing. All allow. + --scenario large-exposure Nordwind Logistik AG, EUR 750k. Exceeds + delegated authority and breaches the + concentration limit. Write denied. + --scenario sanctions-hit Meridian Trading DMCC, EUR 200k. A beneficial + owner matches a sanctions list, so CDD does not + clear and the runtime blocks the write. + +Usage: + python credit_risk_agent.py [--gateway http://localhost:8443] + [--scenario clean|large-exposure|sanctions-hit] +""" + +import argparse +import json +import sys +import httpx + +# Company names in this demo carry German umlauts; force UTF-8 so the console +# output is correct on Windows terminals too. +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +DEFAULT_GATEWAY = "http://localhost:8443" +WORKFLOW_ID = "credit-risk-analyst" +BUREAU = "creditreform" + +SCENARIOS = { + "clean": {"client_id": "DE-CORP-2024-00847", "amount_eur": 250_000}, + "large-exposure": {"client_id": "DE-CORP-2024-01120", "amount_eur": 750_000}, + "sanctions-hit": {"client_id": "AE-CORP-2024-00311", "amount_eur": 200_000}, +} + + +def call_tool(client, gateway, tool_name, arguments, req_id): + payload = { + "jsonrpc": "2.0", + "id": req_id, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments, + "_cmcp": {"workflow_id": WORKFLOW_ID}, + }, + } + resp = client.post(f"{gateway}/mcp", json=payload, timeout=30) + body = resp.json() + if "error" in body: + return {"ok": False, "error": body["error"], "payload": None, "session_id": None} + result = body["result"] + payload_text = result.get("content", [{}])[0].get("text", "{}") + return { + "ok": True, + "payload": json.loads(payload_text), + "session_id": result.get("_cmcp", {}).get("session_id"), + } + + +def print_outcome(step, tool, outcome, note=""): + print(f"[{step}] {tool} ...") + if outcome["ok"]: + print(f" -> decision: allow{(' ' + note) if note else ''}") + else: + data = outcome["error"].get("data", {}) + print(f" -> decision: deny ({data.get('error_code', 'unknown')})") + advice = data.get("advice") + if advice: + print(" advice from policy:") + for key, value in advice.items(): + print(f" {key}: {value}") + + +def close_session(client, gateway, session_id): + resp = client.post(f"{gateway}/sessions/{session_id}/close", timeout=10) + resp.raise_for_status() + return resp.json() + + +def run(gateway, scenario): + sc = SCENARIOS[scenario] + client_id, amount = sc["client_id"], sc["amount_eur"] + print(f"Connecting to cMCP Runtime at {gateway}") + print(f"Scenario: {scenario} | Client: {client_id} | Facility: EUR {amount:,}") + print() + + session_id = None + with httpx.Client(headers={"Content-Type": "application/json"}) as client: + o1 = call_tool(client, gateway, "finance.document_reader", {"client_id": client_id}, 1) + note = "" + if o1["ok"]: + p = o1["payload"] + note = f"{p.get('legal_name')} (LEI {p.get('lei')})" + print_outcome("1/6", "finance.document_reader", o1, note) + session_id = o1.get("session_id") or session_id + + o2 = call_tool(client, gateway, "finance.sanctions_screening", {"client_id": client_id}, 2) + cdd_status = o2["payload"].get("cdd_status") if o2["ok"] else "unknown" + print_outcome("2/6", "finance.sanctions_screening", o2, f"cdd_status={cdd_status}") + session_id = o2.get("session_id") or session_id + + o3 = call_tool(client, gateway, "finance.credit_bureau_lookup", + {"client_id": client_id, "bureau": BUREAU}, 3) + note = "" + if o3["ok"]: + p = o3["payload"] + note = f"Creditreform index {p.get('bonitaetsindex')} ({p.get('assessment')})" + print_outcome("3/6", "finance.credit_bureau_lookup", o3, note) + session_id = o3.get("session_id") or session_id + + o4 = call_tool(client, gateway, "finance.exposure_aggregation", + {"client_id": client_id, "proposed_facility_eur": amount}, 4) + breaches = o4["payload"].get("breaches_concentration_limit") if o4["ok"] else None + aggregate = o4["payload"].get("aggregate_exposure_eur") if o4["ok"] else None + print_outcome("4/6", "finance.exposure_aggregation", o4, + f"aggregate EUR {aggregate:,} breach={breaches}" if aggregate is not None else "") + session_id = o4.get("session_id") or session_id + + o5 = call_tool(client, gateway, "finance.risk_model", + {"client_id": client_id, "proposed_facility_eur": amount}, 5) + rating = o5["payload"].get("internal_rating") if o5["ok"] else None + pd_1y = o5["payload"].get("pd_1y") if o5["ok"] else None + ifrs9_stage = o5["payload"].get("ifrs9_stage") if o5["ok"] else None + print_outcome("5/6", "finance.risk_model", o5, + f"rating {rating} IFRS9 stage {ifrs9_stage}") + session_id = o5.get("session_id") or session_id + + cdd_cleared = cdd_status == "clear" + recommendation = "approve" if (cdd_cleared and not breaches and ifrs9_stage == 1) else "refer" + o6 = call_tool(client, gateway, "finance.risk_report_writer", { + "client_id": client_id, + "internal_rating": rating, + "pd_1y": pd_1y, + "ifrs9_stage": ifrs9_stage, + "amount_eur": amount, + "aggregate_exposure_eur": aggregate, + "breaches_concentration_limit": breaches, + "cdd_cleared": cdd_cleared, + "recommendation": recommendation, + }, 6) + print_outcome("6/6", "finance.risk_report_writer", o6) + session_id = o6.get("session_id") or session_id + + if not o6["ok"]: + print() + print(" The risk report was NOT written to the core banking system.") + + print() + if session_id is None: + print("No session id received - cannot fetch TRACE Trust Record.") + sys.exit(1) + + print(f"Closing session {session_id} and fetching the signed TRACE Trust Record ...") + claim = close_session(client, gateway, session_id) + print() + print("=== TRACE Trust Record (signed RuntimeClaim) ===") + print(json.dumps(claim, indent=2)) + + print() + print("Done. Verify the audit chain with:") + print(f" curl {gateway}/audit/export?session_id={session_id}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="EU corporate credit risk agent demo") + parser.add_argument("--gateway", default=DEFAULT_GATEWAY, + help=f"cMCP Runtime base URL (default: {DEFAULT_GATEWAY})") + parser.add_argument("--scenario", default="clean", choices=sorted(SCENARIOS), + help="which client scenario to run (default: clean)") + args = parser.parse_args() + run(args.gateway, args.scenario) diff --git a/examples/financial-services/catalog.json b/examples/financial-services/catalog.json new file mode 100644 index 00000000..6aefe66e --- /dev/null +++ b/examples/financial-services/catalog.json @@ -0,0 +1,223 @@ +[ + { + "tool_name": "finance.document_reader", + "server": { + "display_name": "EU Credit Risk MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Read filed annual financial statements for a corporate client from the secure document vault", + "input_schema": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "document_id": { + "type": "string" + } + }, + "required": [ + "client_id" + ] + } + }, + "definition_hash": "sha256:24613a660da9855e9c5f0e1cae8a34c2e771b8a29affc3e17fae36f26c0e70f1", + "compliance_domain": "mnpi", + "requires_baa": false, + "sensitivity_level": "confidential", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "risk-committee@bank.eu" + }, + { + "tool_name": "finance.sanctions_screening", + "server": { + "display_name": "EU Credit Risk MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Screen a corporate client and its beneficial owners against EU/UN sanctions and PEP lists (CDD/AML)", + "input_schema": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + } + }, + "required": [ + "client_id" + ] + } + }, + "definition_hash": "sha256:182484c9fb9aacd66b97527b9d1c889ae0a9eac757ae4b50fd4665183aac9022", + "compliance_domain": "pii", + "requires_baa": false, + "sensitivity_level": "confidential", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "risk-committee@bank.eu" + }, + { + "tool_name": "finance.credit_bureau_lookup", + "server": { + "display_name": "EU Credit Risk MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Retrieve a commercial credit-bureau report for a client", + "input_schema": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "bureau": { + "type": "string", + "enum": [ + "creditreform", + "schufa", + "crif", + "banque-de-france-fiben" + ] + } + }, + "required": [ + "client_id" + ] + } + }, + "definition_hash": "sha256:a581f359451d7f8ce2082ae185d991a719458579e23008817781697a535f78f7", + "compliance_domain": "pii", + "requires_baa": false, + "sensitivity_level": "confidential", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "risk-committee@bank.eu" + }, + { + "tool_name": "finance.exposure_aggregation", + "server": { + "display_name": "EU Credit Risk MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Aggregate existing group exposure with the proposed facility and test it against the single-obligor concentration limit", + "input_schema": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "proposed_facility_eur": { + "type": "number" + } + }, + "required": [ + "client_id", + "proposed_facility_eur" + ] + } + }, + "definition_hash": "sha256:aa25379a11f8b6e7b730efcf685f26370c52655b34b9f021ee0f79371c673021", + "compliance_domain": "internal", + "requires_baa": false, + "sensitivity_level": "confidential", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "risk-committee@bank.eu" + }, + { + "tool_name": "finance.risk_model", + "server": { + "display_name": "EU Credit Risk MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Compute PD/LGD/EAD, the internal rating grade and the IFRS 9 stage for a proposed facility", + "input_schema": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "proposed_facility_eur": { + "type": "number" + } + }, + "required": [ + "client_id", + "proposed_facility_eur" + ] + } + }, + "definition_hash": "sha256:5ec6f11017e587afa44af58fd0b70574c02127a0462f3b089cb5deba7566fc20", + "compliance_domain": "internal", + "requires_baa": false, + "sensitivity_level": "confidential", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "risk-committee@bank.eu" + }, + { + "tool_name": "finance.risk_report_writer", + "server": { + "display_name": "EU Credit Risk MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Write the credit risk assessment to the core banking system", + "input_schema": { + "type": "object", + "properties": { + "client_id": { + "type": "string" + }, + "internal_rating": { + "type": "string" + }, + "pd_1y": { + "type": "number" + }, + "ifrs9_stage": { + "type": "number" + }, + "amount_eur": { + "type": "number" + }, + "aggregate_exposure_eur": { + "type": "number" + }, + "breaches_concentration_limit": { + "type": "boolean" + }, + "cdd_cleared": { + "type": "boolean" + }, + "recommendation": { + "type": "string" + } + }, + "required": [ + "client_id", + "amount_eur", + "recommendation", + "cdd_cleared" + ] + } + }, + "definition_hash": "sha256:e92a42ec46321042f852b516426e91d4e5c685130e4c35effcf3fd9c206b9ba6", + "compliance_domain": "internal", + "requires_baa": false, + "sensitivity_level": "confidential", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "risk-committee@bank.eu" + } +] diff --git a/examples/financial-services/cmcp-config.yaml b/examples/financial-services/cmcp-config.yaml new file mode 100644 index 00000000..3555944f --- /dev/null +++ b/examples/financial-services/cmcp-config.yaml @@ -0,0 +1,8 @@ +policy_bundle_path: ./policy +catalog_path: ./catalog.json +listen_addr: 127.0.0.1:8443 # tokenless dev mode binds to loopback; set CMCP_BEARER_TOKEN for non-loopback binds +max_response_size_bytes: 2097152 +attestation: + provider: auto + enforcement_mode: enforcing + validity_seconds: 86400 diff --git a/examples/financial-services/credit_engine.py b/examples/financial-services/credit_engine.py new file mode 100644 index 00000000..23ada41f --- /dev/null +++ b/examples/financial-services/credit_engine.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +""" +Credit-decisioning domain logic for the financial-services example. + +Pure, dependency-free functions plus a small set of realistic EU corporate +client fixtures. The mock MCP server (``server/mock_mcp_server.py``) imports +this module so the tool responses are computed from one source of truth, and +``tests/test_credit_engine.py`` exercises the logic without a running server. + +Everything here is illustrative. The identifiers are valid in *format* (LEI +check digits per ISO 17442 / ISO 7064 MOD 97-10, German IBANs per the same +scheme) but the entities are fictional. +""" + +from __future__ import annotations + +from typing import Any + +# Internal single-obligor concentration limit. Banks set this well below the +# CRR Art. 395 regulatory large-exposures ceiling (25% of Tier 1 capital) as a +# matter of risk appetite; the runtime enforces the internal limit and cites +# CRR Art. 395 as the framework it implements. +CONCENTRATION_LIMIT_EUR = 2_000_000 + +# Facilities above this amount exceed the automated workflow's delegated +# lending authority and require a human credit decision-maker under the EBA +# Guidelines on loan origination and monitoring (EBA/GL/2020/06). +DELEGATED_AUTHORITY_LIMIT_EUR = 500_000 + + +# --------------------------------------------------------------------------- +# Client fixtures. Keyed by the bank's internal customer id. +# --------------------------------------------------------------------------- + +CLIENTS: dict[str, dict[str, Any]] = { + # Scenario A - clean SME, well within limits, performing. + "DE-CORP-2024-00847": { + "legal_name": "Rheintal Präzisionstechnik GmbH", + "lei": "529900RHEINTAL000140", + "creditreform_id": "20401234", + "nace_code": "C25.62", + "nace_label": "Machining", + "iban": "DE89370400440532013000", + "country": "DE", + "ubos": [ + {"name": "Klara Hoffmann", "role": "Managing shareholder (62%)"}, + {"name": "Rheintal Beteiligungs GmbH", "role": "Holding (38%)"}, + ], + "financials": { + "fiscal_year": 2024, + "total_assets_eur": 12_400_000, + "total_equity_eur": 4_960_000, + "total_liabilities_eur": 7_440_000, + "revenue_eur": 18_900_000, + "ebitda_eur": 2_460_000, + "net_debt_eur": 3_100_000, + "auditor_opinion": "unqualified", + }, + "bureau_index": 178, # Creditreform Bonitätsindex (100 best..600) + "existing_group_exposure_eur": 900_000, + "sanctions_hit": False, + }, + # Scenario B - strong obligor but the new facility breaches the internal + # concentration limit and exceeds delegated authority. + "DE-CORP-2024-01120": { + "legal_name": "Nordwind Logistik AG", + "lei": "391200NORDWIND000113", + "creditreform_id": "29004567", + "nace_code": "H52.29", + "nace_label": "Other transportation support activities", + "iban": "DE58500700100923456789", + "country": "DE", + "ubos": [ + {"name": "Nordwind Holding SE", "role": "Parent (100%)"}, + ], + "financials": { + "fiscal_year": 2024, + "total_assets_eur": 47_800_000, + "total_equity_eur": 15_300_000, + "total_liabilities_eur": 32_500_000, + "revenue_eur": 61_200_000, + "ebitda_eur": 7_950_000, + "net_debt_eur": 21_400_000, + "auditor_opinion": "unqualified", + }, + "bureau_index": 205, + "existing_group_exposure_eur": 1_450_000, + "sanctions_hit": False, + }, + # Scenario C - a beneficial owner matches a sanctions / PEP list. + "AE-CORP-2024-00311": { + "legal_name": "Meridian Trading DMCC", + "lei": "529900MERIDIAN000175", + "creditreform_id": None, + "nace_code": "G46.90", + "nace_label": "Non-specialised wholesale trade", + "iban": None, + "country": "AE", + "ubos": [ + {"name": "Dmitri V. Aslanov", "role": "Beneficial owner (55%)"}, + {"name": "Meridian Holdings Ltd", "role": "Holding (45%)"}, + ], + "financials": { + "fiscal_year": 2023, + "total_assets_eur": 3_100_000, + "total_equity_eur": 420_000, + "total_liabilities_eur": 2_680_000, + "revenue_eur": 9_400_000, + "ebitda_eur": 210_000, + "net_debt_eur": 2_260_000, + "auditor_opinion": "not_available", + }, + "bureau_index": None, + "existing_group_exposure_eur": 0, + "sanctions_hit": True, + }, +} + +DEFAULT_CLIENT = "DE-CORP-2024-00847" + +# Internal rating masterscale: (upper bound of Creditreform index, grade, PD). +_RATING_BANDS = [ + (150, "1b", 0.0018), + (200, "2b", 0.0061), + (250, "3b", 0.0140), + (300, "4b", 0.0325), + (350, "5b", 0.0710), + (600, "6b", 0.1600), +] + + +def _client(client_id: str) -> dict[str, Any]: + return CLIENTS.get(client_id, CLIENTS[DEFAULT_CLIENT]) + + +def read_financials(client_id: str) -> dict[str, Any]: + """Return the latest filed financials plus derived ratios.""" + c = _client(client_id) + f = c["financials"] + equity_ratio = round(f["total_equity_eur"] / f["total_assets_eur"], 3) + net_debt_to_ebitda = ( + round(f["net_debt_eur"] / f["ebitda_eur"], 2) if f["ebitda_eur"] else None + ) + ebitda_margin = round(f["ebitda_eur"] / f["revenue_eur"], 3) if f["revenue_eur"] else None + return { + "client_id": client_id, + "legal_name": c["legal_name"], + "lei": c["lei"], + "nace_code": c["nace_code"], + "nace_label": c["nace_label"], + "document_type": "annual_financial_statements", + "fiscal_year": f["fiscal_year"], + "total_assets_eur": f["total_assets_eur"], + "total_equity_eur": f["total_equity_eur"], + "total_liabilities_eur": f["total_liabilities_eur"], + "revenue_eur": f["revenue_eur"], + "ebitda_eur": f["ebitda_eur"], + "net_debt_eur": f["net_debt_eur"], + "equity_ratio": equity_ratio, + "ebitda_margin": ebitda_margin, + "net_debt_to_ebitda": net_debt_to_ebitda, + "auditor_opinion": f["auditor_opinion"], + "status": "retrieved", + } + + +def screen_sanctions(client_id: str) -> dict[str, Any]: + """Screen the entity and its beneficial owners (CDD / AML).""" + c = _client(client_id) + lists = [ + "EU Consolidated Financial Sanctions (CFSP)", + "UN Security Council Consolidated List", + "PEP", + ] + matches: list[dict[str, Any]] = [] + if c["sanctions_hit"]: + matches.append({ + "matched_entity": c["ubos"][0]["name"], + "match_type": "beneficial_owner", + "list": "EU Consolidated Financial Sanctions (CFSP)", + "score": 0.94, + }) + return { + "client_id": client_id, + "legal_name": c["legal_name"], + "lei": c["lei"], + "lists_checked": lists, + "entities_screened": [c["legal_name"]] + [u["name"] for u in c["ubos"]], + "matches": matches, + "cdd_status": "hit" if matches else "clear", + "status": "completed", + } + + +def bureau_report(client_id: str, bureau: str = "creditreform") -> dict[str, Any]: + """Return a commercial credit-bureau report. + + Uses the Creditreform Bonitätsindex scale (100 best, 600 = hard negative), + not a US FICO-style score. + """ + c = _client(client_id) + index = c["bureau_index"] + return { + "client_id": client_id, + "bureau": bureau, + "creditreform_id": c["creditreform_id"], + "bonitaetsindex": index, + "scale": "100-600 (100 = excellent, 600 = hard negative)", + "assessment": _bureau_band(index), + "status": "retrieved" if index is not None else "no_file", + } + + +def _bureau_band(index: int | None) -> str: + if index is None: + return "no_commercial_credit_file" + if index <= 149: + return "excellent" + if index <= 199: + return "very_good" + if index <= 249: + return "good" + if index <= 299: + return "medium" + if index <= 349: + return "weak" + return "very_weak" + + +def aggregate_exposure(client_id: str, proposed_facility_eur: int) -> dict[str, Any]: + """Aggregate existing group exposure with the proposed facility and test it + against the internal single-obligor concentration limit.""" + c = _client(client_id) + existing = c["existing_group_exposure_eur"] + aggregate = existing + proposed_facility_eur + utilisation = round(aggregate / CONCENTRATION_LIMIT_EUR, 3) + return { + "client_id": client_id, + "existing_group_exposure_eur": existing, + "proposed_facility_eur": proposed_facility_eur, + "aggregate_exposure_eur": aggregate, + "concentration_limit_eur": CONCENTRATION_LIMIT_EUR, + "limit_utilisation": utilisation, + "breaches_concentration_limit": aggregate > CONCENTRATION_LIMIT_EUR, + "framework": "CRR Art. 395 (large exposures); internal risk-appetite limit", + "status": "completed", + } + + +def run_risk_model(client_id: str, proposed_facility_eur: int) -> dict[str, Any]: + """Compute PD / LGD / EAD, the internal rating grade and the IFRS 9 stage.""" + c = _client(client_id) + index = c["bureau_index"] + grade, pd = _rating_from_index(index) + f = c["financials"] + # A simple, legible IFRS 9 staging rule for the demo: unaudited or very + # weak files are treated as under-performing / credit-impaired. + if f["auditor_opinion"] == "not_available" or (index is not None and index > 349): + ifrs9_stage = 3 + elif index is not None and index > 249: + ifrs9_stage = 2 + else: + ifrs9_stage = 1 + lgd = 0.35 # senior secured corporate, illustrative + ead = proposed_facility_eur + expected_loss = round(pd * lgd * ead) if pd is not None else None + return { + "client_id": client_id, + "internal_rating": grade, + "pd_1y": pd, + "lgd": lgd, + "ead_eur": ead, + "expected_loss_eur": expected_loss, + "ifrs9_stage": ifrs9_stage, + "model_id": "corp-pd-lgd-v2.3", + "status": "completed", + } + + +def _rating_from_index(index: int | None) -> tuple[str | None, float | None]: + if index is None: + return "unrated", None + for upper, grade, pd in _RATING_BANDS: + if index <= upper: + return grade, pd + return "6b", 0.16 diff --git a/examples/financial-services/policy/allow.cedar b/examples/financial-services/policy/allow.cedar new file mode 100644 index 00000000..298f26a7 --- /dev/null +++ b/examples/financial-services/policy/allow.cedar @@ -0,0 +1,136 @@ +// Cedar policy bundle for EU corporate credit risk agent +// version: credit-risk-v5.0 +// author: compliance@bank.eu +// +// Action names follow the cMCP convention: the segment before the dot is +// PascalCase and each underscore segment after it is camelCase, e.g. +// finance.risk_report_writer becomes Action::"Finance.riskReportWriter". +// +// There is deliberately NO catch-all permit: anything not explicitly +// permitted below is denied (Cedar default-deny). The workflow_id is supplied +// by the agent via the _cmcp request metadata; calls without it are denied. + +// --- Workflow-scoped permits ------------------------------------------------- +// The credit-risk-analyst workflow may run each step of the assessment. + +permit ( + principal, + action == Action::"Finance.documentReader", + resource +) when { + context has workflow_id && context.workflow_id == "credit-risk-analyst" +}; + +permit ( + principal, + action == Action::"Finance.sanctionsScreening", + resource +) when { + context has workflow_id && context.workflow_id == "credit-risk-analyst" +}; + +permit ( + principal, + action == Action::"Finance.creditBureauLookup", + resource +) when { + context has workflow_id && context.workflow_id == "credit-risk-analyst" +}; + +permit ( + principal, + action == Action::"Finance.exposureAggregation", + resource +) when { + context has workflow_id && context.workflow_id == "credit-risk-analyst" +}; + +permit ( + principal, + action == Action::"Finance.riskModel", + resource +) when { + context has workflow_id && context.workflow_id == "credit-risk-analyst" +}; + +permit ( + principal, + action == Action::"Finance.riskReportWriter", + resource +) when { + context has workflow_id && context.workflow_id == "credit-risk-analyst" +}; + +// --- Guardrails on writing the assessment ------------------------------------ +// Each forbid overrides the permits above and carries structured advice back +// to the caller in the deny response (error.data.advice). + +// Customer due diligence must be cleared before any assessment is written. +// A sanctions or PEP hit leaves cdd_cleared false and blocks the write, so the +// gate is enforced by the runtime rather than left to the agent's good +// behaviour. +@id("cdd-clearance-required") +@reason("cdd-clearance-required") +@regulation("eu-aml-regulation-2024-1624") +forbid ( + principal, + action == Action::"Finance.riskReportWriter", + resource +) when { + !(context.arguments has cdd_cleared) || context.arguments.cdd_cleared != true +}; + +// Facilities above the delegated lending authority require a human credit +// decision-maker (EBA loan-origination governance). +@id("delegated-authority-hitl") +@reason("human-review-required") +@regulation("eba-gl-2020-06") +@delegated_authority_limit_eur("500000") +forbid ( + principal, + action == Action::"Finance.riskReportWriter", + resource +) when { + context.arguments has amount_eur && context.arguments.amount_eur > 500000 +}; + +// The proposed facility must not breach the single-obligor concentration +// limit (the internal implementation of the CRR large-exposures regime). +@id("large-exposure-concentration") +@reason("concentration-limit-breached") +@regulation("crr-art-395") +forbid ( + principal, + action == Action::"Finance.riskReportWriter", + resource +) when { + context.arguments has breaches_concentration_limit && + context.arguments.breaches_concentration_limit == true +}; + +// Credit-impaired obligors (IFRS 9 stage 3) cannot be auto-approved; they go +// to manual restructuring review. +@id("credit-impaired-manual-review") +@reason("ifrs9-stage-3-credit-impaired") +@regulation("ifrs-9") +forbid ( + principal, + action == Action::"Finance.riskReportWriter", + resource +) when { + context.arguments has ifrs9_stage && context.arguments.ifrs9_stage == 3 +}; + +// Confidential (mnpi) financial data may only flow through an attested +// runtime. attestation_platform is supplied by the runtime from the TEE +// attestation report; "unknown" means no attestation evidence is present. +@id("require-attested-runtime") +@reason("attested-runtime-required") +@regulation("dora-art-9") +forbid ( + principal, + action, + resource +) when { + context.compliance_domain == "mnpi" && context.attestation_platform == "unknown" +}; diff --git a/examples/financial-services/policy/manifest.json b/examples/financial-services/policy/manifest.json new file mode 100644 index 00000000..6dc321b6 --- /dev/null +++ b/examples/financial-services/policy/manifest.json @@ -0,0 +1,6 @@ +{ + "version": "credit-risk-v5.0", + "authored_at": "2026-06-01T00:00:00Z", + "author_identity": "compliance@bank.eu", + "commit_sha": "abc123def456" +} diff --git a/examples/financial-services/policy/schema.cedarschema b/examples/financial-services/policy/schema.cedarschema new file mode 100644 index 00000000..889ad3f5 --- /dev/null +++ b/examples/financial-services/policy/schema.cedarschema @@ -0,0 +1 @@ +{"cMCP": {}} diff --git a/examples/financial-services/server/mock_mcp_server.py b/examples/financial-services/server/mock_mcp_server.py new file mode 100644 index 00000000..2e46c278 --- /dev/null +++ b/examples/financial-services/server/mock_mcp_server.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +""" +Mock EU Credit Risk MCP Server for the financial-services demo. + +Serves the six catalog tools on port 8080. Tool responses are computed by +``credit_engine`` from a small set of realistic EU corporate client fixtures, +so the server, the tests and the agent all agree on the same data. + +Stdlib only -- no dependencies. + +Usage: + python financial-services/server/mock_mcp_server.py +""" + +import json +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EXAMPLE_DIR)) + +import credit_engine # noqa: E402 + +PORT = 8080 + + +def _document_reader(args: dict) -> str: + return json.dumps(credit_engine.read_financials(args.get("client_id", credit_engine.DEFAULT_CLIENT))) + + +def _sanctions_screening(args: dict) -> str: + return json.dumps(credit_engine.screen_sanctions(args.get("client_id", credit_engine.DEFAULT_CLIENT))) + + +def _credit_bureau_lookup(args: dict) -> str: + return json.dumps(credit_engine.bureau_report( + args.get("client_id", credit_engine.DEFAULT_CLIENT), + args.get("bureau", "creditreform"), + )) + + +def _exposure_aggregation(args: dict) -> str: + return json.dumps(credit_engine.aggregate_exposure( + args.get("client_id", credit_engine.DEFAULT_CLIENT), + int(args.get("proposed_facility_eur", 0)), + )) + + +def _risk_model(args: dict) -> str: + return json.dumps(credit_engine.run_risk_model( + args.get("client_id", credit_engine.DEFAULT_CLIENT), + int(args.get("proposed_facility_eur", 0)), + )) + + +def _risk_report_writer(args: dict) -> str: + return json.dumps({ + "client_id": args.get("client_id", ""), + "internal_rating": args.get("internal_rating"), + "recommendation": args.get("recommendation"), + "amount_eur": args.get("amount_eur"), + "report_id": "RR-2026-04471", + "status": "written", + }) + + +TOOLS = { + "finance.document_reader": _document_reader, + "finance.sanctions_screening": _sanctions_screening, + "finance.credit_bureau_lookup": _credit_bureau_lookup, + "finance.exposure_aggregation": _exposure_aggregation, + "finance.risk_model": _risk_model, + "finance.risk_report_writer": _risk_report_writer, +} + + +# Upstream definitions are independent of the gateway catalog used for drift checks. +TOOL_DEFINITIONS = [ + { + "name": "finance.document_reader", + "description": "Read filed annual financial statements for a corporate client from the secure " + "document vault", + "inputSchema": { + "type": "object", + "properties": { + "client_id": {"type": "string"}, + "document_id": {"type": "string"}, + }, + "required": ["client_id"], + }, + }, + { + "name": "finance.sanctions_screening", + "description": "Screen a corporate client and its beneficial owners against EU/UN sanctions and " + "PEP lists (CDD/AML)", + "inputSchema": { + "type": "object", + "properties": {"client_id": {"type": "string"}}, + "required": ["client_id"], + }, + }, + { + "name": "finance.credit_bureau_lookup", + "description": "Retrieve a commercial credit-bureau report for a client", + "inputSchema": { + "type": "object", + "properties": { + "client_id": {"type": "string"}, + "bureau": { + "type": "string", + "enum": [ + "creditreform", + "schufa", + "crif", + "banque-de-france-fiben", + ], + }, + }, + "required": ["client_id"], + }, + }, + { + "name": "finance.exposure_aggregation", + "description": "Aggregate existing group exposure with the proposed facility and test it against " + "the single-obligor concentration limit", + "inputSchema": { + "type": "object", + "properties": { + "client_id": {"type": "string"}, + "proposed_facility_eur": {"type": "number"}, + }, + "required": ["client_id", "proposed_facility_eur"], + }, + }, + { + "name": "finance.risk_model", + "description": "Compute PD/LGD/EAD, the internal rating grade and the IFRS 9 stage for a " + "proposed facility", + "inputSchema": { + "type": "object", + "properties": { + "client_id": {"type": "string"}, + "proposed_facility_eur": {"type": "number"}, + }, + "required": ["client_id", "proposed_facility_eur"], + }, + }, + { + "name": "finance.risk_report_writer", + "description": "Write the credit risk assessment to the core banking system", + "inputSchema": { + "type": "object", + "properties": { + "client_id": {"type": "string"}, + "internal_rating": {"type": "string"}, + "pd_1y": {"type": "number"}, + "ifrs9_stage": {"type": "number"}, + "amount_eur": {"type": "number"}, + "aggregate_exposure_eur": {"type": "number"}, + "breaches_concentration_limit": {"type": "boolean"}, + "cdd_cleared": {"type": "boolean"}, + "recommendation": {"type": "string"}, + }, + "required": ["client_id", "amount_eur", "recommendation", "cdd_cleared"], + }, + }, +] + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): # noqa: N802 + if self.path != "/mcp": + self._reply(404, {"error": "not found"}) + return + length = int(self.headers.get("Content-Length", 0)) + request = json.loads(self.rfile.read(length)) + method = request.get("method", "") + if method == "tools/list": + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "result": {"tools": TOOL_DEFINITIONS}, + }) + return + if method != "tools/call": + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "error": {"code": -32601, "message": f"unknown method: {method}"}, + }) + return + params = request.get("params", {}) + tool = params.get("name", "") + handler = TOOLS.get(tool) + if handler is None: + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "error": {"code": -32601, "message": f"unknown tool: {tool}"}, + }) + return + text = handler(params.get("arguments", {})) + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "result": {"content": [{"type": "text", "text": text}]}, + }) + + def _reply(self, status: int, body: dict) -> None: + payload = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, fmt, *args): + print(f"[mock-mcp] {fmt % args}") + + +if __name__ == "__main__": + print(f"Mock EU Credit Risk MCP Server listening on :{PORT} (tools: {', '.join(TOOLS)})") + HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/examples/financial-services/tests/test_credit_engine.py b/examples/financial-services/tests/test_credit_engine.py new file mode 100644 index 00000000..3136c5ff --- /dev/null +++ b/examples/financial-services/tests/test_credit_engine.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EXAMPLE_DIR)) + +import credit_engine # noqa: E402 + + +class CreditEngineTests(unittest.TestCase): + CLEAN = "DE-CORP-2024-00847" + LARGE = "DE-CORP-2024-01120" + SANCTIONED = "AE-CORP-2024-00311" + + def test_lei_check_digits_are_valid(self) -> None: + """Every fixture LEI passes the ISO 7064 MOD 97-10 check.""" + for client in credit_engine.CLIENTS.values(): + lei = client["lei"] + self.assertEqual(len(lei), 20, lei) + digits = "".join(str(int(c, 36)) if c.isalpha() else c for c in lei) + self.assertEqual(int(digits) % 97, 1, f"invalid LEI check digits: {lei}") + + def test_clean_client_clears_cdd(self) -> None: + result = credit_engine.screen_sanctions(self.CLEAN) + self.assertEqual(result["cdd_status"], "clear") + self.assertEqual(result["matches"], []) + + def test_sanctioned_ubo_produces_a_hit(self) -> None: + result = credit_engine.screen_sanctions(self.SANCTIONED) + self.assertEqual(result["cdd_status"], "hit") + self.assertEqual(result["matches"][0]["match_type"], "beneficial_owner") + + def test_bureau_uses_creditreform_scale_not_fico(self) -> None: + report = credit_engine.bureau_report(self.CLEAN) + self.assertEqual(report["bonitaetsindex"], 178) + self.assertEqual(report["assessment"], "very_good") + self.assertIn("100-600", report["scale"]) + + def test_exposure_within_limit_does_not_breach(self) -> None: + agg = credit_engine.aggregate_exposure(self.CLEAN, 250_000) + self.assertEqual(agg["aggregate_exposure_eur"], 1_150_000) + self.assertFalse(agg["breaches_concentration_limit"]) + + def test_exposure_over_limit_breaches(self) -> None: + agg = credit_engine.aggregate_exposure(self.LARGE, 750_000) + self.assertEqual(agg["aggregate_exposure_eur"], 2_200_000) + self.assertTrue(agg["breaches_concentration_limit"]) + + def test_performing_client_is_stage_1_and_rated(self) -> None: + model = credit_engine.run_risk_model(self.CLEAN, 250_000) + self.assertEqual(model["ifrs9_stage"], 1) + self.assertEqual(model["internal_rating"], "2b") + self.assertEqual(model["ead_eur"], 250_000) + self.assertIsNotNone(model["expected_loss_eur"]) + + def test_unaudited_client_is_credit_impaired(self) -> None: + model = credit_engine.run_risk_model(self.SANCTIONED, 200_000) + self.assertEqual(model["ifrs9_stage"], 3) + + def test_financial_ratios_are_derived(self) -> None: + fin = credit_engine.read_financials(self.CLEAN) + self.assertEqual(fin["equity_ratio"], 0.4) + self.assertEqual(fin["net_debt_to_ebitda"], 1.26) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/financial-services/trace-output/clean-trust-record.json b/examples/financial-services/trace-output/clean-trust-record.json new file mode 100644 index 00000000..5226a998 --- /dev/null +++ b/examples/financial-services/trace-output/clean-trust-record.json @@ -0,0 +1,128 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1784610809, + "subject": "spiffe://cmcp.gateway/tee/e36c15f8abfcb5c4", + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:6ed4ecc2813edf8e8e86fefc5781df3162b81bfe2ba18c44e6d6ae054d318a79", + "enforcement_mode": "enforce", + "version": "credit-risk-v5.0" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:5d8f7ced02b34bd905f8f061d1811537a165fd169eef200fc8777b5e5d2189e2", + "call_count": 6, + "entries": [ + { + "tool_name": "finance.document_reader", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.sanctions_screening", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.credit_bureau_lookup", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.exposure_aggregation", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.risk_model", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.risk_report_writer", + "data_class": "confidential", + "decision": "allow" + } + ] + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "42wV-Kv8tcSo8JfX42tAsleWpFqY7yp_cf3IUSfynPs", + "kid": "cmcp-e36c15f8" + } + } + }, + "gateway": { + "session_id": "394e8095-55be-4100-b417-010c9b1da7e0", + "gateway_version": "0.3.0", + "sequence_number": 2, + "prev_claim_hash": "sha256:9c91f4c53a505b65f9ed7e8d76f45803e76640ce3ee32ece478dbe4147c92b4c", + "audit_chain": { + "root": "0d5885626021fa38c1be2a3954fb4ef6c3022ee21666643fa05fa13012c757d8", + "tip": "5d8f7ced02b34bd905f8f061d1811537a165fd169eef200fc8777b5e5d2189e2", + "length": 8 + }, + "call_summary": { + "tool_calls_total": 6, + "tool_calls_allowed": 6, + "tool_calls_denied": 0, + "tool_calls_faulted": 0, + "tools_invoked": [ + "finance.credit_bureau_lookup", + "finance.document_reader", + "finance.exposure_aggregation", + "finance.risk_model", + "finance.risk_report_writer", + "finance.sanctions_screening" + ], + "session_max_sensitivity": "confidential", + "call_graph_summary": { + "compliance_domains_touched": [ + "internal", + "mnpi", + "pii" + ], + "cross_boundary_events": [ + { + "from_domain": "pii", + "to_domain": "internal", + "call_id": "6dade2f6-89f7-426e-bd77-a59bea604615", + "tool_name": "finance.exposure_aggregation", + "sequence_number": 3 + } + ], + "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." + } + }, + "catalog": { + "hash": "sha256:4332bdb99e95034e582019c93a176674bc2b2280c872e96ec574c2da8b342e03", + "drift_detected": false + }, + "attestation_generated_at": "2026-07-21T05:12:13.092919+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 6, + "tools_called": [ + "finance.document_reader", + "finance.sanctions_screening", + "finance.credit_bureau_lookup", + "finance.exposure_aggregation", + "finance.risk_model", + "finance.risk_report_writer" + ], + "suspicious_sequences_detected": 0 + }, + "kill_switch_triggered": false + }, + "signature": "0oHUJaLnHAxueJtoLvrd4gzCgn8g-zDIwAu4JJ3zAV-BeXeJIVp3669N26ADb5B7EJu51dj8m_Q0T6VUHnf2CA" +} diff --git a/examples/financial-services/trace-output/large-exposure-trust-record.json b/examples/financial-services/trace-output/large-exposure-trust-record.json new file mode 100644 index 00000000..5331dd16 --- /dev/null +++ b/examples/financial-services/trace-output/large-exposure-trust-record.json @@ -0,0 +1,128 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1784610814, + "subject": "spiffe://cmcp.gateway/tee/e36c15f8abfcb5c4", + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:6ed4ecc2813edf8e8e86fefc5781df3162b81bfe2ba18c44e6d6ae054d318a79", + "enforcement_mode": "enforce", + "version": "credit-risk-v5.0" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:f2a45e0baa83eaaf3500d78e7d2207ea34692e3963fe611e8087447c21300f1c", + "call_count": 6, + "entries": [ + { + "tool_name": "finance.document_reader", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.sanctions_screening", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.credit_bureau_lookup", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.exposure_aggregation", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.risk_model", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.risk_report_writer", + "data_class": "confidential", + "decision": "deny" + } + ] + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "42wV-Kv8tcSo8JfX42tAsleWpFqY7yp_cf3IUSfynPs", + "kid": "cmcp-e36c15f8" + } + } + }, + "gateway": { + "session_id": "f752c840-7c73-452d-a984-1887417d2d5d", + "gateway_version": "0.3.0", + "sequence_number": 3, + "prev_claim_hash": "sha256:2800f6ce64289cf0f92c3aabd5b4be6bec0815cd475bbbd699cc8a9cc87a4f25", + "audit_chain": { + "root": "3377da44e3074a7a1b956db8ccd0a1ed41178b85c12ae8810047fde243cb4260", + "tip": "f2a45e0baa83eaaf3500d78e7d2207ea34692e3963fe611e8087447c21300f1c", + "length": 8 + }, + "call_summary": { + "tool_calls_total": 6, + "tool_calls_allowed": 5, + "tool_calls_denied": 1, + "tool_calls_faulted": 0, + "tools_invoked": [ + "finance.credit_bureau_lookup", + "finance.document_reader", + "finance.exposure_aggregation", + "finance.risk_model", + "finance.risk_report_writer", + "finance.sanctions_screening" + ], + "session_max_sensitivity": "confidential", + "call_graph_summary": { + "compliance_domains_touched": [ + "internal", + "mnpi", + "pii" + ], + "cross_boundary_events": [ + { + "from_domain": "pii", + "to_domain": "internal", + "call_id": "60e3a027-8c58-4275-84cd-0ebbf2669399", + "tool_name": "finance.exposure_aggregation", + "sequence_number": 3 + } + ], + "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." + } + }, + "catalog": { + "hash": "sha256:4332bdb99e95034e582019c93a176674bc2b2280c872e96ec574c2da8b342e03", + "drift_detected": false + }, + "attestation_generated_at": "2026-07-21T05:12:13.092919+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 6, + "tools_called": [ + "finance.document_reader", + "finance.sanctions_screening", + "finance.credit_bureau_lookup", + "finance.exposure_aggregation", + "finance.risk_model", + "finance.risk_report_writer" + ], + "suspicious_sequences_detected": 0 + }, + "kill_switch_triggered": false + }, + "signature": "KdjlwOfVLf_-nrdvpXOTcl2KSNKTpBWwdWHu91dKdxQHfxvgT35ivW-yhSi9mbC7v3uiM9DQ6UDaongMUN-OBQ" +} diff --git a/examples/financial-services/trace-output/sanctions-hit-trust-record.json b/examples/financial-services/trace-output/sanctions-hit-trust-record.json new file mode 100644 index 00000000..5220a755 --- /dev/null +++ b/examples/financial-services/trace-output/sanctions-hit-trust-record.json @@ -0,0 +1,128 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1784610818, + "subject": "spiffe://cmcp.gateway/tee/e36c15f8abfcb5c4", + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:6ed4ecc2813edf8e8e86fefc5781df3162b81bfe2ba18c44e6d6ae054d318a79", + "enforcement_mode": "enforce", + "version": "credit-risk-v5.0" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:78c0e5c9f30f2053fc66ddc6476f434a5c24d9f4d5a0cd745929a04d4f4b2885", + "call_count": 6, + "entries": [ + { + "tool_name": "finance.document_reader", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.sanctions_screening", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.credit_bureau_lookup", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.exposure_aggregation", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.risk_model", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "finance.risk_report_writer", + "data_class": "confidential", + "decision": "deny" + } + ] + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "42wV-Kv8tcSo8JfX42tAsleWpFqY7yp_cf3IUSfynPs", + "kid": "cmcp-e36c15f8" + } + } + }, + "gateway": { + "session_id": "bf50e9f3-77d9-4f00-93d3-8fa84f656875", + "gateway_version": "0.3.0", + "sequence_number": 4, + "prev_claim_hash": "sha256:54f88db30aabb98564ad6b7a548a9078b33b8cdee1004fdc1818b48c3812c46d", + "audit_chain": { + "root": "dc5afb2b6d292c998d1772bbe6d3f9ed1912d558e3c336d8d2297f2d273d89d7", + "tip": "78c0e5c9f30f2053fc66ddc6476f434a5c24d9f4d5a0cd745929a04d4f4b2885", + "length": 8 + }, + "call_summary": { + "tool_calls_total": 6, + "tool_calls_allowed": 5, + "tool_calls_denied": 1, + "tool_calls_faulted": 0, + "tools_invoked": [ + "finance.credit_bureau_lookup", + "finance.document_reader", + "finance.exposure_aggregation", + "finance.risk_model", + "finance.risk_report_writer", + "finance.sanctions_screening" + ], + "session_max_sensitivity": "confidential", + "call_graph_summary": { + "compliance_domains_touched": [ + "internal", + "mnpi", + "pii" + ], + "cross_boundary_events": [ + { + "from_domain": "pii", + "to_domain": "internal", + "call_id": "3d7fa065-5648-418e-a73e-e83b9a7dd4ee", + "tool_name": "finance.exposure_aggregation", + "sequence_number": 3 + } + ], + "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." + } + }, + "catalog": { + "hash": "sha256:4332bdb99e95034e582019c93a176674bc2b2280c872e96ec574c2da8b342e03", + "drift_detected": false + }, + "attestation_generated_at": "2026-07-21T05:12:13.092919+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 6, + "tools_called": [ + "finance.document_reader", + "finance.sanctions_screening", + "finance.credit_bureau_lookup", + "finance.exposure_aggregation", + "finance.risk_model", + "finance.risk_report_writer" + ], + "suspicious_sequences_detected": 0 + }, + "kill_switch_triggered": false + }, + "signature": "k4hytlJSgqejJapn89SHRf-49kxvLB9tXd-YFb7MRNpEjGuywJUWTkwxFxkP18cLzxeYRC8WNCVE0y6RVMMCDA" +} diff --git a/examples/healthcare/README.md b/examples/healthcare/README.md new file mode 100644 index 00000000..edcd6e1f --- /dev/null +++ b/examples/healthcare/README.md @@ -0,0 +1,256 @@ +# healthcare: Clinical Decision Support Agent Demo + +End-to-end demo of a hospital AI agent running a clinical assessment through a cMCP Runtime with Cedar policy enforcement and signed TRACE Trust Records for healthcare regulatory compliance (EU AI Act Art. 14, HIPAA). + +The worked patient is a fictional 54-year-old with type 2 diabetes and hypertension. Diagnoses carry ICD-10 codes, medications carry dosing, and the agent runs a drug-interaction check whose result feeds the human-oversight guardrails, so a deny reflects the real safety outcome of the plan. + +--- + +## What the demo shows + +**1. EU AI Act Article 14 - human oversight for high-risk AI** +The Cedar policy blocks any treatment plan write where `patient_risk_category == "high"`. The deny response carries the policy's `@annotation` metadata as structured advice (`regulation: eu-ai-act-art-14`, `reviewer_role: attending-physician`), and the audit chain records the deny as machine-readable Art. 14 evidence. + +**2. A medication-safety guardrail that fires on the assessment result** +The agent runs `ehr.drug_interaction_check` against the patient's current medications and documented allergies, then passes `has_severe_contraindication` into the write call. A Cedar rule blocks the write when a severe contraindication is present, so the guardrail acts on the actual interaction result rather than on a static flag. + +**3. HIPAA PHI protection at the tool boundary** +All four tools are classified `compliance_domain: hipaa_phi` in the attested catalog. A Cedar rule forbids PHI tools when the runtime reports no attestation platform at all (`attestation_platform == "unknown"`). A software-only dev-mode runtime reports `software-only` and passes this gate, which is how the demo runs without a TEE. To require hardware attestation for PHI, forbid unless `attestation_platform` is one of the hardware platforms you accept. + +**4. Cryptographic proof of the tool call sequence** +Every call is recorded in a hash-chained audit log persisted to SQLite. Closing the session seals the chain into a signed `RuntimeClaim` (the TRACE Trust Record): which tools ran, in what order, what was denied - verifiable without trusting the agent process. + +**5. Three demo scenarios** +`--scenario standard` (all four calls allowed), `--scenario high-risk` (Art. 14 block on a high-risk patient), `--scenario contraindication` (a proposed drug that the patient is allergic to blocks the write). + +--- + +## Architecture + +``` + +------------------------------------------------------------------+ + | Clinical Decision Support Agent (LLM) | + | agent/clinical_decision_agent.py -- JSON-RPC 2.0 over HTTP | + +-------------------------------+----------------------------------+ + | tools/call (MCP) + v + +------------------------------------------------------------------+ + | cMCP Runtime :8443 | + | | + | +---------------+ +------------------+ +------------------+ | + | | Cedar engine | | Catalog checker | | Audit chain + | | + | | allow.cedar | | catalog.json | | TRACE signer | | + | +---------------+ +------------------+ +------------------+ | + +-------------------------------+----------------------------------+ + | proxied tool call + v + +------------------------------------------------------------------+ + | Mock Hospital EHR MCP Server :8080 | + | server/mock_mcp_server.py | + | ehr.patient_record_lookup | + | ehr.clinical_decision_support | + | ehr.drug_interaction_check | + | ehr.treatment_plan_writer | + +------------------------------------------------------------------+ +``` + +--- + +## Run it + +```bash +git clone https://github.com/agentrust-io/integrations.git +cd integrations/examples +pip install cmcp-runtime httpx +``` + +**Terminal 1 - mock EHR server:** + +```bash +cd healthcare +python server/mock_mcp_server.py +``` + +**Terminal 2 - runtime** (run from inside `healthcare/` - config paths resolve relative to the working directory): + +```bash +cd healthcare +CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml +``` + +**Terminal 3 - the three scenarios:** + +```bash +cd integrations/examples +# A. standard: performing plan, no interaction -> all four steps allow +python healthcare/agent/clinical_decision_agent.py --scenario standard + +# B. high-risk patient -> Art. 14 human-oversight block on the write +python healthcare/agent/clinical_decision_agent.py --scenario high-risk + +# C. a proposed drug the patient is allergic to -> contraindication block +python healthcare/agent/clinical_decision_agent.py --scenario contraindication +``` + +Standard scenario: + +``` +Scenario: standard | Patient: [redacted PHI] | Risk category: standard + +[1/4] ehr.patient_record_lookup ... + -> decision: allow active dx: E11.9, I10, E78.5 +[2/4] ehr.clinical_decision_support ... + -> decision: allow Type 2 diabetes mellitus, suboptimal glycaemic control +[3/4] ehr.drug_interaction_check ... + -> decision: allow highest_severity=none +[4/4] ehr.treatment_plan_writer ... + -> decision: allow +``` + +Contraindication scenario (the patient has a documented sulfonamide allergy, so proposing co-trimoxazole trips a severe contraindication): + +``` +[3/4] ehr.drug_interaction_check ... + -> decision: allow highest_severity=severe +[4/4] ehr.treatment_plan_writer ... + -> decision: deny (POLICY_DENY) + advice from policy: + id: medication-contraindication + reason: severe-contraindication-detected + regulation: eu-ai-act-art-14 + reviewer_role: attending-physician + + The treatment plan was NOT written to the EHR. + An attending physician must review and approve before the plan takes effect. +``` + +--- + +## The Cedar policy + +`policy/allow.cedar` has no catch-all permit: each EHR tool is explicitly permitted only for the `clinical-decision-support` workflow (declared by the agent via `_cmcp.workflow_id`), and anything else - wrong workflow, missing workflow, unlisted action - is denied by Cedar's default-deny: + +```cedar +permit ( + principal, + action == Action::"Ehr.patientRecordLookup", + resource +) when { + context has workflow_id && + context.workflow_id == "clinical-decision-support" +}; +``` + +On top of the workflow-scoped permits sit three forbid rules (high-risk human oversight, severe medication contraindication, and the HIPAA attestation gate). Annotations on a `forbid` are returned to the caller as structured advice when that rule causes a deny: + +```cedar +@id("hitl-high-risk") +@reason("human-review-required") +@regulation("eu-ai-act-art-14") +@reviewer_role("attending-physician") +forbid ( + principal, + action == Action::"Ehr.treatmentPlanWriter", + resource +) when { + context.arguments has patient_risk_category && + context.arguments.patient_risk_category == "high" +}; +``` + +The second forbid blocks the write when the drug-interaction check returned a severe contraindication: + +```cedar +@id("medication-contraindication") +@reason("severe-contraindication-detected") +@regulation("eu-ai-act-art-14") +@reviewer_role("attending-physician") +forbid ( + principal, + action == Action::"Ehr.treatmentPlanWriter", + resource +) when { + context.arguments has has_severe_contraindication && + context.arguments.has_severe_contraindication == true +}; +``` + +Action names follow the cMCP convention: `ehr.treatment_plan_writer` becomes `Action::"Ehr.treatmentPlanWriter"` (PascalCase per underscore segment). Tool arguments are available under `context.arguments`. + +--- + +## The TRACE Trust Record + +`trace-output/` holds one signed record per scenario (`standard-trust-record.json`, `high-risk-trust-record.json`, `contraindication-trust-record.json`), captured from real runs. Verify one with `cmcp verify trace-output/high-risk-trust-record.json` (schema, signature and audit chain pass; hardware attestation fails in software-only dev mode). Key fields: + +| Field | Meaning | +|---|---| +| `trace.policy.bundle_hash` / `version` | Exactly which Cedar bundle was enforced (`clinical-safety-v3.0`) | +| `trace.data_class` | Highest sensitivity touched in the session (`confidential`) | +| `trace.tool_transcript.hash` | Hash of the audit chain tip covering all calls | +| `trace.cnf.jwk` | The runtime's Ed25519 signing key (verifies `signature`) | +| `gateway.call_summary` | Allowed/denied counts, tools invoked, compliance domains touched | +| `gateway.audit_chain` | Root, tip, and length of the hash-chained audit log | +| `signature` | Ed25519 signature over the canonical claim | + +Export the full audit chain for a closed session: + +```bash +curl "http://localhost:8443/audit/export?session_id=" | python3 -m json.tool +``` + +--- + +## Regulatory field mapping + +| TRACE field | EU AI Act | HIPAA | +|---|---|---| +| `trace.policy.bundle_hash` | Art. 9 - risk management system version | 45 CFR 164.312 - access controls | +| `gateway.call_summary.tool_calls_denied` | Art. 14 - human oversight record | 45 CFR 164.308(a)(1)(ii)(D) - activity review | +| `trace.data_class` | Art. 10 - data governance | 45 CFR 164.502 - minimum necessary | +| `trace.runtime` + `signature` | Art. 12 - tamper-evident logging | 45 CFR 164.312(c) - integrity | +| `trace.subject` | Art. 12 - traceability to specific run | 45 CFR 164.308(a)(5) - access monitoring | + +--- + +## Extending this example + +- **Real EHR server:** point `server.url` in `catalog.json` at your MCP server. +- **Hardware attestation:** drop `CMCP_DEV_MODE=1` on a VM with TPM 2.0 / SEV-SNP; `trace.runtime` then carries real measurements. +- **Production hardening:** set `CMCP_BEARER_TOKEN`, `CMCP_POLICY_HASH`, and `CMCP_CATALOG_HASH` (the runtime refuses to start without them outside dev mode). + +--- + +## The tests + +`tests/test_clinical_engine.py` checks that diagnoses carry ICD-10 codes, that the differential matches the record, that an appropriate second-line agent is safe, and that a sulfonamide allergy and a drug-drug interaction are both detected. + +```bash +python -m unittest discover -s tests -v +``` + +--- + +## Regulatory Variants + +This demo uses EU AI Act Art. 14 and HIPAA as its primary policy example. Additional +variants in subdirectories show how the same cMCP + TRACE architecture maps to other +healthcare regulatory frameworks. The Cedar policy and TRACE record fields change; +the runtime architecture does not. + +| Variant | Jurisdiction | Regulatory focus | +|---------|-------------|-----------------| +| This demo | EU + US | EU AI Act Art. 14 human oversight + HIPAA PHI | +| [`us-fda-samd/`](us-fda-samd/README.md) | United States | FDA SaMD Action Plan -- cleared-scope enforcement, 21 CFR Part 820 | +| [`uk-nhs/`](uk-nhs/README.md) | United Kingdom | UK GDPR Art. 22 -- DSPT token gate, MHRA AI as medical device | +| [`sg-moh/`](sg-moh/README.md) | Singapore | IMDA AI Governance Tier 1/2 -- PDPA consent, MOH guidelines | + +Each variant includes a Cedar policy file showing jurisdiction-specific rules and a +TRACE Trust Record with the `runtime.region`, `runtime.provider`, and +`compliance_domains_touched` fields set for that jurisdiction. + +--- + +## License + +Apache 2.0. See [LICENSE](../LICENSE) in the repo root. diff --git a/examples/healthcare/agent/clinical_decision_agent.py b/examples/healthcare/agent/clinical_decision_agent.py new file mode 100644 index 00000000..c81e191b --- /dev/null +++ b/examples/healthcare/agent/clinical_decision_agent.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +""" +Clinical decision support agent demo for hospital AI compliance. + +Runs a four-step workflow through the cMCP Runtime using JSON-RPC 2.0 over HTTP, +then closes the session to obtain the signed TRACE Trust Record. The agent runs +a drug-interaction check and passes its result into the write call, so the +guardrails act on the real safety outcome. + +Scenarios: + + --scenario standard Add empagliflozin (second-line), standard risk. + Interaction check clean. All four steps allow. + --scenario high-risk Same plan, patient_risk_category=high. The write + is blocked for human oversight (EU AI Act Art. 14). + --scenario contraindication Propose co-trimoxazole for an incidental UTI. + The patient has a documented sulfonamide allergy, + so the interaction check flags a severe + contraindication and the write is blocked. + +Usage: + python clinical_decision_agent.py [--gateway http://localhost:8443] + [--scenario standard|high-risk|contraindication] +""" + +import argparse +import json +import sys +import httpx + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +DEFAULT_GATEWAY = "http://localhost:8443" +WORKFLOW_ID = "clinical-decision-support" +PATIENT_ID = "P-2024-008471" +SYMPTOMS = ["fatigue", "polyuria", "polydipsia", "blurred vision"] + +SCENARIOS = { + "standard": { + "risk": "standard", + "proposed": ["empagliflozin"], + "treatment": "Continue metformin 500mg BID; add empagliflozin 10mg once daily; HbA1c recheck in 3 months", + }, + "high-risk": { + "risk": "high", + "proposed": ["empagliflozin"], + "treatment": "Continue metformin 500mg BID; add empagliflozin 10mg once daily; HbA1c recheck in 3 months", + }, + "contraindication": { + "risk": "standard", + "proposed": ["co-trimoxazole"], + "treatment": "Add co-trimoxazole 960mg BID for 3 days (incidental urinary tract infection)", + }, +} + + +def call_tool(client, gateway, tool_name, arguments, req_id): + payload = { + "jsonrpc": "2.0", + "id": req_id, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments, + "_cmcp": {"workflow_id": WORKFLOW_ID}, + }, + } + resp = client.post(f"{gateway}/mcp", json=payload, timeout=30) + body = resp.json() + if "error" in body: + return {"ok": False, "error": body["error"], "payload": None, "session_id": None} + result = body["result"] + payload_text = result.get("content", [{}])[0].get("text", "{}") + return {"ok": True, "payload": json.loads(payload_text), + "session_id": result.get("_cmcp", {}).get("session_id")} + + +def print_outcome(step, tool, outcome, note=""): + print(f"[{step}] {tool} ...") + if outcome["ok"]: + print(f" -> decision: allow{(' ' + note) if note else ''}") + else: + data = outcome["error"].get("data", {}) + print(f" -> decision: deny ({data.get('error_code', 'unknown')})") + advice = data.get("advice") + if advice: + print(" advice from policy:") + for key, value in advice.items(): + print(f" {key}: {value}") + + +def close_session(client, gateway, session_id): + resp = client.post(f"{gateway}/sessions/{session_id}/close", timeout=10) + resp.raise_for_status() + return resp.json() + + +def run(gateway, scenario): + sc = SCENARIOS[scenario] + print(f"Connecting to cMCP Runtime at {gateway}") + # This is a PHI-handling demo, so the patient identifier is deliberately not + # written to stdout in clear text; it travels only in the tool arguments and + # the signed TRACE record (trace.subject). + print(f"Scenario: {scenario} | Patient: [redacted PHI] | Risk category: {sc['risk']}") + print() + + session_id = None + with httpx.Client(headers={"Content-Type": "application/json"}) as client: + o1 = call_tool(client, gateway, "ehr.patient_record_lookup", + {"patient_id": PATIENT_ID, "record_type": "full"}, 1) + note = "" + if o1["ok"]: + dx = ", ".join(d["icd10"] for d in o1["payload"].get("active_diagnoses", [])) + note = f"active dx: {dx}" + print_outcome("1/4", "ehr.patient_record_lookup", o1, note) + session_id = o1.get("session_id") or session_id + + o2 = call_tool(client, gateway, "ehr.clinical_decision_support", + {"patient_id": PATIENT_ID, "presenting_symptoms": SYMPTOMS}, 2) + note = "" + if o2["ok"] and o2["payload"].get("differential"): + note = o2["payload"]["differential"][0]["condition"] + print_outcome("2/4", "ehr.clinical_decision_support", o2, note) + session_id = o2.get("session_id") or session_id + + o3 = call_tool(client, gateway, "ehr.drug_interaction_check", + {"patient_id": PATIENT_ID, "proposed_medications": sc["proposed"]}, 3) + has_contra = bool(o3["payload"].get("has_severe_contraindication")) if o3["ok"] else False + print_outcome("3/4", "ehr.drug_interaction_check", o3, + f"highest_severity={o3['payload'].get('highest_severity')}" if o3["ok"] else "") + session_id = o3.get("session_id") or session_id + + o4 = call_tool(client, gateway, "ehr.treatment_plan_writer", { + "patient_id": PATIENT_ID, + "diagnosis": "Type 2 diabetes mellitus with hypertension", + "treatment": sc["treatment"], + "patient_risk_category": sc["risk"], + "has_severe_contraindication": has_contra, + }, 4) + print_outcome("4/4", "ehr.treatment_plan_writer", o4) + session_id = o4.get("session_id") or session_id + + if not o4["ok"]: + print() + print(" The treatment plan was NOT written to the EHR.") + print(" An attending physician must review and approve before the plan takes effect.") + + print() + if session_id is None: + print("No session id received - cannot fetch TRACE Trust Record.") + sys.exit(1) + + print(f"Closing session {session_id} and fetching the signed TRACE Trust Record ...") + claim = close_session(client, gateway, session_id) + print() + print("=== TRACE Trust Record (signed RuntimeClaim) ===") + print(json.dumps(claim, indent=2)) + + print() + print("Done. Verify the audit chain with:") + print(f" curl {gateway}/audit/export?session_id={session_id}") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Clinical decision support agent demo") + parser.add_argument("--gateway", default=DEFAULT_GATEWAY, + help=f"cMCP Runtime base URL (default: {DEFAULT_GATEWAY})") + parser.add_argument("--scenario", default="standard", choices=sorted(SCENARIOS), + help="which clinical scenario to run (default: standard)") + args = parser.parse_args() + run(args.gateway, args.scenario) diff --git a/examples/healthcare/catalog.json b/examples/healthcare/catalog.json new file mode 100644 index 00000000..3a07a0e6 --- /dev/null +++ b/examples/healthcare/catalog.json @@ -0,0 +1,165 @@ +[ + { + "tool_name": "ehr.patient_record_lookup", + "server": { + "display_name": "Hospital EHR MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Look up patient clinical record from the EHR system", + "input_schema": { + "type": "object", + "properties": { + "patient_id": { + "type": "string" + }, + "record_type": { + "type": "string", + "enum": [ + "demographics", + "diagnoses", + "medications", + "labs", + "vitals", + "full" + ] + } + }, + "required": [ + "patient_id" + ] + } + }, + "definition_hash": "sha256:8849c45c3aab0a43885a66b44a836578a2e6c5f006154d5cda2bb72ba5c85be3", + "compliance_domain": "hipaa_phi", + "requires_baa": true, + "sensitivity_level": "confidential", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "compliance@hospital.example" + }, + { + "tool_name": "ehr.clinical_decision_support", + "server": { + "display_name": "Hospital EHR MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Run AI-assisted differential diagnosis and clinical decision support", + "input_schema": { + "type": "object", + "properties": { + "patient_id": { + "type": "string" + }, + "presenting_symptoms": { + "type": "array", + "items": { + "type": "string" + } + }, + "lab_values": { + "type": "object" + } + }, + "required": [ + "patient_id", + "presenting_symptoms" + ] + } + }, + "definition_hash": "sha256:7a4320c8f4f12c1350ab74bbf266510e6a732f6bbb921ac78c717ba5621f5d20", + "compliance_domain": "hipaa_phi", + "requires_baa": true, + "sensitivity_level": "confidential", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "compliance@hospital.example" + }, + { + "tool_name": "ehr.drug_interaction_check", + "server": { + "display_name": "Hospital EHR MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Check proposed medications against the patient's current medications and documented allergies", + "input_schema": { + "type": "object", + "properties": { + "patient_id": { + "type": "string" + }, + "proposed_medications": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "patient_id", + "proposed_medications" + ] + } + }, + "definition_hash": "sha256:7d68570e326a9dde2ca0d2f1fe65b5be6e7219fa5d29a3ddd0d75d8b382e5acc", + "compliance_domain": "hipaa_phi", + "requires_baa": true, + "sensitivity_level": "confidential", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "compliance@hospital.example" + }, + { + "tool_name": "ehr.treatment_plan_writer", + "server": { + "display_name": "Hospital EHR MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Write a treatment plan to the patient record", + "input_schema": { + "type": "object", + "properties": { + "patient_id": { + "type": "string" + }, + "diagnosis": { + "type": "string" + }, + "treatment": { + "type": "string" + }, + "patient_risk_category": { + "type": "string", + "enum": [ + "standard", + "high" + ] + }, + "has_severe_contraindication": { + "type": "boolean" + } + }, + "required": [ + "patient_id", + "diagnosis", + "treatment", + "patient_risk_category" + ] + } + }, + "definition_hash": "sha256:a2e568165b6a2d3e9dd3f20f4b1df4fe76bc865113dd3007d7ed6a0d265d1e9d", + "compliance_domain": "hipaa_phi", + "requires_baa": true, + "sensitivity_level": "confidential", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "compliance@hospital.example" + } +] diff --git a/examples/healthcare/clinical_engine.py b/examples/healthcare/clinical_engine.py new file mode 100644 index 00000000..bc8451c4 --- /dev/null +++ b/examples/healthcare/clinical_engine.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +""" +Clinical domain logic for the healthcare example. + +Pure, dependency-free functions plus one coherent patient fixture. The mock EHR +server serves from this module, and tests/test_clinical_engine.py exercises the +logic without a running server, so the record, the differential and the +drug-interaction check all agree on the same patient. + +Everything here is illustrative and fictional. Diagnoses carry ICD-10 codes, +medications carry dosing, and the differential is consistent with the record. +""" + +from __future__ import annotations + +from typing import Any + +# One worked patient: a 54-year-old with type 2 diabetes and hypertension whose +# glycaemic control has drifted (HbA1c 8.1%). +PATIENT: dict[str, Any] = { + "patient_id": "P-2024-008471", + "age": 54, + "sex": "female", + "active_diagnoses": [ + {"icd10": "E11.9", "label": "Type 2 diabetes mellitus without complications"}, + {"icd10": "I10", "label": "Essential (primary) hypertension"}, + {"icd10": "E78.5", "label": "Hyperlipidaemia, unspecified"}, + ], + "current_medications": [ + {"name": "metformin", "dose": "500 mg", "frequency": "twice daily"}, + {"name": "lisinopril", "dose": "10 mg", "frequency": "once daily"}, + {"name": "atorvastatin", "dose": "20 mg", "frequency": "once daily"}, + ], + "allergies": [ + {"substance": "penicillin", "reaction": "urticaria"}, + {"substance": "sulfonamides", "reaction": "rash"}, + ], + "labs": { + "hba1c_percent": 8.1, + "fasting_glucose_mmol": 9.2, + "egfr_ml_min": 78, + "ldl_mmol": 2.9, + "blood_pressure_mmhg": "148/92", + "bmi": 31.4, + }, + "last_visit": "2026-05-28", +} + +# Small illustrative interaction knowledge base. +# Drug classes the patient's allergies contraindicate. +_ALLERGY_CONTRAINDICATIONS = { + "sulfonamides": ["co-trimoxazole", "sulfamethoxazole", "sulfasalazine"], + "penicillin": ["amoxicillin", "ampicillin", "co-amoxiclav", "piperacillin"], +} +# Pairwise drug-drug interactions with the patient's current medications. +_DRUG_DRUG = { + ("lisinopril", "spironolactone"): ("moderate", "additive hyperkalaemia risk"), + ("lisinopril", "potassium chloride"): ("severe", "hyperkalaemia risk"), + ("atorvastatin", "clarithromycin"): ("severe", "increased myopathy/rhabdomyolysis risk"), +} + + +def patient_record(patient_id: str, record_type: str = "full") -> dict[str, Any]: + """Return the patient record, or the requested section of it.""" + p = PATIENT + full = { + "patient_id": patient_id or p["patient_id"], + "age": p["age"], + "sex": p["sex"], + "active_diagnoses": p["active_diagnoses"], + "current_medications": p["current_medications"], + "allergies": p["allergies"], + "labs": p["labs"], + "last_visit": p["last_visit"], + } + sections = { + "demographics": {"patient_id": full["patient_id"], "age": p["age"], "sex": p["sex"]}, + "diagnoses": {"patient_id": full["patient_id"], "active_diagnoses": p["active_diagnoses"]}, + "medications": {"patient_id": full["patient_id"], "current_medications": p["current_medications"]}, + "labs": {"patient_id": full["patient_id"], "labs": p["labs"]}, + "vitals": {"patient_id": full["patient_id"], "blood_pressure_mmhg": p["labs"]["blood_pressure_mmhg"], "bmi": p["labs"]["bmi"]}, + } + out = sections.get(record_type, full) + out["record_type"] = record_type + out["status"] = "retrieved" + return out + + +def clinical_decision_support(patient_id: str, presenting_symptoms: list[str] | None = None) -> dict[str, Any]: + """Differential and recommendations consistent with the patient record.""" + return { + "patient_id": patient_id or PATIENT["patient_id"], + "presenting_symptoms": presenting_symptoms or ["fatigue", "polyuria", "polydipsia"], + "differential": [ + {"condition": "Type 2 diabetes mellitus, suboptimal glycaemic control", + "icd10": "E11.65", "confidence": 0.93}, + {"condition": "Metabolic syndrome", "icd10": "E88.81", "confidence": 0.68}, + ], + "assessment": "HbA1c 8.1% is above the individualised target; first-line metformin " + "alone is insufficient. Consider adding a second-line agent.", + "recommended_actions": [ + "Intensify glycaemic control (add second-line agent)", + "Reinforce lifestyle measures", + "Recheck HbA1c in 3 months", + ], + "status": "completed", + } + + +def drug_interaction_check(patient_id: str, proposed_medications: list[str]) -> dict[str, Any]: + """Check proposed medications against current meds and documented allergies.""" + proposed = [m.strip().lower() for m in (proposed_medications or [])] + allergies = [a["substance"] for a in PATIENT["allergies"]] + current = [m["name"] for m in PATIENT["current_medications"]] + + contraindications: list[dict[str, Any]] = [] + for allergy in allergies: + for drug in _ALLERGY_CONTRAINDICATIONS.get(allergy, []): + if drug in proposed: + contraindications.append({ + "proposed": drug, + "type": "allergy", + "detail": f"contraindicated: documented {allergy} allergy", + "severity": "severe", + }) + + interactions: list[dict[str, Any]] = [] + for cur in current: + for prop in proposed: + hit = _DRUG_DRUG.get((cur, prop)) or _DRUG_DRUG.get((prop, cur)) + if hit: + severity, detail = hit + interactions.append({ + "current": cur, "proposed": prop, + "type": "drug-drug", "detail": detail, "severity": severity, + }) + + flags = contraindications + interactions + has_severe = any(f["severity"] == "severe" for f in flags) + return { + "patient_id": patient_id or PATIENT["patient_id"], + "proposed_medications": proposed, + "contraindications": contraindications, + "interactions": interactions, + "highest_severity": _highest_severity(flags), + "has_severe_contraindication": has_severe, + "status": "completed", + } + + +def _highest_severity(flags: list[dict[str, Any]]) -> str: + order = {"none": 0, "moderate": 1, "severe": 2} + best = "none" + for f in flags: + if order.get(f["severity"], 0) > order[best]: + best = f["severity"] + return best diff --git a/examples/healthcare/cmcp-config.yaml b/examples/healthcare/cmcp-config.yaml new file mode 100644 index 00000000..461fe2b2 --- /dev/null +++ b/examples/healthcare/cmcp-config.yaml @@ -0,0 +1,8 @@ +policy_bundle_path: ./policy +catalog_path: ./catalog.json +listen_addr: 127.0.0.1:8443 # tokenless dev mode binds to loopback; set CMCP_BEARER_TOKEN for non-loopback binds +audit_db_path: ./audit.db +attestation: + provider: auto + enforcement_mode: enforcing + validity_seconds: 86400 diff --git a/examples/healthcare/policy/allow.cedar b/examples/healthcare/policy/allow.cedar new file mode 100644 index 00000000..ac22f33c --- /dev/null +++ b/examples/healthcare/policy/allow.cedar @@ -0,0 +1,89 @@ +// Cedar policy bundle for hospital clinical decision support agent +// version: clinical-safety-v3.0 +// author: compliance@hospital.example +// +// Action names follow the cMCP convention: the segment before the dot is +// PascalCase and each underscore segment after it is camelCase, e.g. +// ehr.treatment_plan_writer becomes Action::"Ehr.treatmentPlanWriter". +// +// There is deliberately NO catch-all permit: anything not explicitly +// permitted below is denied (Cedar default-deny). The workflow_id is supplied +// by the agent via the _cmcp request metadata; calls without it are denied. + +// --- Workflow-scoped permits ------------------------------------------------- + +permit ( + principal, + action == Action::"Ehr.patientRecordLookup", + resource +) when { + context has workflow_id && context.workflow_id == "clinical-decision-support" +}; + +permit ( + principal, + action == Action::"Ehr.clinicalDecisionSupport", + resource +) when { + context has workflow_id && context.workflow_id == "clinical-decision-support" +}; + +permit ( + principal, + action == Action::"Ehr.drugInteractionCheck", + resource +) when { + context has workflow_id && context.workflow_id == "clinical-decision-support" +}; + +permit ( + principal, + action == Action::"Ehr.treatmentPlanWriter", + resource +) when { + context has workflow_id && context.workflow_id == "clinical-decision-support" +}; + +// --- Human oversight on writing the treatment plan --------------------------- + +// EU AI Act Article 14 human oversight. Treatment plan writes for high-risk +// patients are blocked until an attending physician approves. +@id("hitl-high-risk") +@reason("human-review-required") +@regulation("eu-ai-act-art-14") +@reviewer_role("attending-physician") +forbid ( + principal, + action == Action::"Ehr.treatmentPlanWriter", + resource +) when { + context.arguments has patient_risk_category && + context.arguments.patient_risk_category == "high" +}; + +// A treatment plan carrying a severe drug interaction or allergy +// contraindication cannot be written automatically; it goes to a clinician. +@id("medication-contraindication") +@reason("severe-contraindication-detected") +@regulation("eu-ai-act-art-14") +@reviewer_role("attending-physician") +forbid ( + principal, + action == Action::"Ehr.treatmentPlanWriter", + resource +) when { + context.arguments has has_severe_contraindication && + context.arguments.has_severe_contraindication == true +}; + +// HIPAA PHI may only flow through an attested runtime. +@id("require-attested-runtime") +@reason("attested-runtime-required") +@regulation("hipaa-164-312") +forbid ( + principal, + action, + resource +) when { + context.compliance_domain == "hipaa_phi" && context.attestation_platform == "unknown" +}; diff --git a/examples/healthcare/policy/manifest.json b/examples/healthcare/policy/manifest.json new file mode 100644 index 00000000..dc668d66 --- /dev/null +++ b/examples/healthcare/policy/manifest.json @@ -0,0 +1,6 @@ +{ + "version": "clinical-safety-v3.0", + "authored_at": "2026-06-01T00:00:00Z", + "author_identity": "compliance@hospital.example", + "commit_sha": "0000000000000000000000000000000000000000" +} diff --git a/examples/healthcare/policy/schema.cedarschema b/examples/healthcare/policy/schema.cedarschema new file mode 100644 index 00000000..889ad3f5 --- /dev/null +++ b/examples/healthcare/policy/schema.cedarschema @@ -0,0 +1 @@ +{"cMCP": {}} diff --git a/examples/healthcare/server/mock_mcp_server.py b/examples/healthcare/server/mock_mcp_server.py new file mode 100644 index 00000000..2056850e --- /dev/null +++ b/examples/healthcare/server/mock_mcp_server.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +Mock Hospital EHR MCP Server for the healthcare demo. + +Serves the four catalog tools on port 8080. Tool responses come from +``clinical_engine`` so the server, the tests and the agent share one coherent +patient. Stdlib only -- no dependencies. + +Usage: + python healthcare/server/mock_mcp_server.py +""" + +import json +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EXAMPLE_DIR)) + +import clinical_engine # noqa: E402 + +PORT = 8080 + + +def _patient_record_lookup(args: dict) -> str: + return json.dumps(clinical_engine.patient_record( + args.get("patient_id", ""), args.get("record_type", "full"))) + + +def _clinical_decision_support(args: dict) -> str: + return json.dumps(clinical_engine.clinical_decision_support( + args.get("patient_id", ""), args.get("presenting_symptoms"))) + + +def _drug_interaction_check(args: dict) -> str: + return json.dumps(clinical_engine.drug_interaction_check( + args.get("patient_id", ""), args.get("proposed_medications", []))) + + +def _treatment_plan_writer(args: dict) -> str: + return json.dumps({ + "patient_id": args.get("patient_id", ""), + "diagnosis": args.get("diagnosis"), + "treatment": args.get("treatment"), + "patient_risk_category": args.get("patient_risk_category"), + "plan_id": "TP-2026-08831", + "status": "written", + }) + + +TOOLS = { + "ehr.patient_record_lookup": _patient_record_lookup, + "ehr.clinical_decision_support": _clinical_decision_support, + "ehr.drug_interaction_check": _drug_interaction_check, + "ehr.treatment_plan_writer": _treatment_plan_writer, +} + + +# Upstream definitions are independent of the gateway catalog used for drift checks. +TOOL_DEFINITIONS = [ + { + "name": "ehr.patient_record_lookup", + "description": "Look up patient clinical record from the EHR system", + "inputSchema": { + "type": "object", + "properties": { + "patient_id": {"type": "string"}, + "record_type": { + "type": "string", + "enum": [ + "demographics", + "diagnoses", + "medications", + "labs", + "vitals", + "full", + ], + }, + }, + "required": ["patient_id"], + }, + }, + { + "name": "ehr.clinical_decision_support", + "description": "Run AI-assisted differential diagnosis and clinical decision support", + "inputSchema": { + "type": "object", + "properties": { + "patient_id": {"type": "string"}, + "presenting_symptoms": {"type": "array", "items": {"type": "string"}}, + "lab_values": {"type": "object"}, + }, + "required": ["patient_id", "presenting_symptoms"], + }, + }, + { + "name": "ehr.drug_interaction_check", + "description": "Check proposed medications against the patient's current medications and documented allergies", + "inputSchema": { + "type": "object", + "properties": { + "patient_id": {"type": "string"}, + "proposed_medications": {"type": "array", "items": {"type": "string"}}, + }, + "required": ["patient_id", "proposed_medications"], + }, + }, + { + "name": "ehr.treatment_plan_writer", + "description": "Write a treatment plan to the patient record", + "inputSchema": { + "type": "object", + "properties": { + "patient_id": {"type": "string"}, + "diagnosis": {"type": "string"}, + "treatment": {"type": "string"}, + "patient_risk_category": { + "type": "string", + "enum": ["standard", "high"], + }, + "has_severe_contraindication": {"type": "boolean"}, + }, + "required": [ + "patient_id", + "diagnosis", + "treatment", + "patient_risk_category", + ], + }, + }, +] + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): # noqa: N802 + if self.path != "/mcp": + self._reply(404, {"error": "not found"}) + return + length = int(self.headers.get("Content-Length", 0)) + request = json.loads(self.rfile.read(length)) + method = request.get("method", "") + if method == "tools/list": + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "result": {"tools": TOOL_DEFINITIONS}, + }) + return + if method != "tools/call": + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "error": {"code": -32601, "message": f"unknown method: {method}"}, + }) + return + params = request.get("params", {}) + tool = params.get("name", "") + handler = TOOLS.get(tool) + if handler is None: + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "error": {"code": -32601, "message": f"unknown tool: {tool}"}, + }) + return + text = handler(params.get("arguments", {})) + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "result": {"content": [{"type": "text", "text": text}]}, + }) + + def _reply(self, status: int, body: dict) -> None: + payload = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, fmt, *args): + print(f"[mock-ehr] {fmt % args}") + + +if __name__ == "__main__": + print(f"Mock Hospital EHR MCP Server listening on :{PORT} (tools: {', '.join(TOOLS)})") + HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/examples/healthcare/sg-moh/README.md b/examples/healthcare/sg-moh/README.md new file mode 100644 index 00000000..fa464d25 --- /dev/null +++ b/examples/healthcare/sg-moh/README.md @@ -0,0 +1,58 @@ +# Singapore MOH: Clinical AI Governance with TRACE + +Demonstrates TRACE Trust Records for an AI healthcare deployment in Singapore. Shows how +the governance record maps to IMDA AI Governance Framework Tier 1/2 classification, +Singapore MOH AI in Healthcare guidelines, PDPA consent requirements, and HSA medical +device registration obligations. + +**Regulatory references:** IMDA AI Governance Framework v2 (2020), MOH Singapore AI in +Healthcare Guidelines (2023), PDPA 2012, HSA guidance on AI/ML-based medical devices. + +--- + +## What the Cedar policy enforces + +| Rule | Regulatory basis | What it blocks | +|------|-----------------|---------------| +| Default deny | -- | Anything not explicitly permitted | +| `imda-tier1-human-review` | IMDA AI Governance Framework v2 -- Tier 1 (consequential) | Final diagnostic outputs without `human_review_token` when `imda_tier == "tier1"` | +| `pdpa-consent-required` | PDPA 2012 -- sensitive personal data | Imaging reads when no `patient_consent_ref` is present in context | +| `sg-data-residency` | PDPA Part 9 -- cross-border transfer obligations | Calls where `data_residency != "ap-southeast-1"` | + +**Key difference from EU/US demos:** IMDA's two-tier model is explicit in the policy -- +`imda_tier` is a context field on every tool call. Tier 1 (consequential decisions, e.g. +diagnosis affecting treatment) requires human review unconditionally. Tier 2 +(non-consequential) does not. This is a different gating model than the EU risk-category +or US acuity-level approaches. PDPA consent reference is also enforced at the call layer, +not just at data collection time. + +--- + +## TRACE Trust Record: key fields for MOH / PDPA audit + +```json +{ + "runtime": { "region": "ap-southeast-1", "provider": "aws-nitro-enclaves" }, + "policy": { "version": "radiology-sg-v1.0", "enforcement_mode": "enforce" }, + "call_graph_summary": { + "compliance_domains_touched": ["sensitive-personal-data", "imda-ai-governance-framework-v2", "pdpa-2012"], + "data_residency_violations": [], + "consent_ref_present": true, + "imda_tier": "tier1" + } +} +``` + +`imda_tier: "tier1"` in the record confirms the session was treated as a consequential +decision -- meaning the human review gate was active for the duration of the session. + +--- + +## Relationship to other healthcare variants + +| Variant | Jurisdiction | Key differentiator | +|---------|-------------|-------------------| +| Base demo (`../`) | EU + US | EU AI Act Art. 14 + HIPAA | +| `../us-fda-samd/` | US FDA | Cleared-scope enforcement, SaMD Action Plan | +| `../uk-nhs/` | UK | UK GDPR Art. 22, DSPT token gate, MHRA oversight | +| This demo | Singapore | IMDA Tier 1/2 consequential-decision gate, PDPA consent | diff --git a/examples/healthcare/sg-moh/policy/radiology-sg.cedar b/examples/healthcare/sg-moh/policy/radiology-sg.cedar new file mode 100644 index 00000000..160e3c8e --- /dev/null +++ b/examples/healthcare/sg-moh/policy/radiology-sg.cedar @@ -0,0 +1,86 @@ +// Cedar policy bundle for AI in healthcare -- Singapore +// version: radiology-sg-v1.0 +// Regulatory references: +// MOH Singapore: Artificial Intelligence in Healthcare Guidelines (2023) +// IMDA AI Governance Framework v2 (2020) -- Tier 1/2 risk classification +// PDPA (Personal Data Protection Act 2012) -- health data as sensitive personal data +// HSA (Health Sciences Authority): guidance on AI/ML-based medical devices +// +// Cedar default-deny. workflow_id required on every call. +// IMDA Tier 1 = consequential, Tier 2 = non-consequential. +// Radiology diagnosis is Tier 1 (consequential decision). + +// Rule 1: imaging read permitted for the sg-radiology-ai workflow. +permit ( + principal, + action == Action::"Radiology.imagingRead", + resource +) when { + context has workflow_id && + context.workflow_id == "sg-radiology-ai" +}; + +// Rule 2: inference permitted within HSA-registered device scope. +permit ( + principal, + action == Action::"Radiology.runDiagnosticInference", + resource +) when { + context has workflow_id && + context.workflow_id == "sg-radiology-ai" +}; + +// Rule 3: report write permitted, subject to human oversight rules below. +permit ( + principal, + action == Action::"Radiology.writeDiagnosticReport", + resource +) when { + context has workflow_id && + context.workflow_id == "sg-radiology-ai" +}; + +// Rule 4: IMDA Tier 1 (consequential decisions) -- all final diagnostic outputs +// require human review before release. MOH guidelines: AI must augment, not replace, +// clinical judgment for consequential decisions. +@id("imda-tier1-human-review") +@reason("human-review-required-for-tier1-decision") +@regulation("imda-ai-governance-framework-v2") +@reviewer_role("radiologist") +forbid ( + principal, + action == Action::"Radiology.writeDiagnosticReport", + resource +) when { + context.arguments has imda_tier && + context.arguments.imda_tier == "tier1" && + !(context has human_review_token) +}; + +// Rule 5: PDPA -- health data is sensitive personal data requiring explicit consent +// record. Deny access if no consent reference is present in context. +@id("pdpa-consent-required") +@reason("pdpa-sensitive-data-consent-required") +@regulation("pdpa-2012") +forbid ( + principal, + action == Action::"Radiology.imagingRead", + resource +) when { + context.data_class == "sensitive-personal-data" && + !(context has patient_consent_ref) +}; + +// Rule 6: health data must remain within Singapore or countries with equivalent +// PDPA protection. Singapore has not granted adequacy to all jurisdictions. +@id("sg-data-residency") +@reason("pdpa-cross-border-transfer-requires-protection") +@regulation("pdpa-2012-part-9") +forbid ( + principal, + action, + resource +) when { + context has data_residency && + context.data_residency != "ap-southeast-1" +}; diff --git a/examples/healthcare/sg-moh/trace-output/example-sg-trust-record.json b/examples/healthcare/sg-moh/trace-output/example-sg-trust-record.json new file mode 100644 index 00000000..80ab3289 --- /dev/null +++ b/examples/healthcare/sg-moh/trace-output/example-sg-trust-record.json @@ -0,0 +1,60 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1781280300, + "subject": "spiffe://cmcp.gateway/session/sg-moh-demo-session-001", + "runtime": { + "platform": "amd-sev-snp", + "measurement": "sha256:d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7", + "firmware_version": "amd-sev-snp-milan-1.55.21", + "region": "ap-southeast-1", + "provider": "aws-nitro-enclaves" + }, + "policy": { + "bundle_hash": "sha256:f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9", + "enforcement_mode": "enforce", + "version": "radiology-sg-v1.0" + }, + "data_class": "sensitive-personal-data", + "tool_transcript": { + "hash": "sha256:b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1", + "call_count": 3 + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "l6c3ZuLwXZ3GxUEoW9TW205GQi5mpO4GxlkSu3c3z58", + "kid": "cmcp-sg-moh-01" + } + } + }, + "gateway": { + "session_id": "sg-moh-demo-session-001", + "gateway_version": "1.0.0", + "sequence_number": 3, + "audit_chain": { + "root": "d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5", + "tip": "b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1", + "length": 5 + }, + "call_summary": { + "tool_calls_total": 3, + "tool_calls_allowed": 3, + "tool_calls_denied": 0, + "tools_invoked": [ + "radiology.imaging_read", + "radiology.run_diagnostic_inference", + "radiology.write_diagnostic_report" + ], + "session_max_sensitivity": "sensitive-personal-data", + "call_graph_summary": { + "compliance_domains_touched": ["sensitive-personal-data", "imda-ai-governance-framework-v2", "pdpa-2012"], + "data_residency_violations": [], + "consent_ref_present": true, + "imda_tier": "tier1" + } + } + } +} diff --git a/examples/healthcare/tests/test_clinical_engine.py b/examples/healthcare/tests/test_clinical_engine.py new file mode 100644 index 00000000..9f8e9139 --- /dev/null +++ b/examples/healthcare/tests/test_clinical_engine.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EXAMPLE_DIR)) + +import clinical_engine as ce # noqa: E402 + +PATIENT = "P-2024-008471" + + +class ClinicalEngineTests(unittest.TestCase): + def test_record_diagnoses_carry_icd10(self) -> None: + rec = ce.patient_record(PATIENT, "full") + codes = {d["icd10"] for d in rec["active_diagnoses"]} + self.assertIn("E11.9", codes) # type 2 diabetes + self.assertIn("I10", codes) # hypertension + + def test_record_sections_are_scoped(self) -> None: + meds = ce.patient_record(PATIENT, "medications") + self.assertIn("current_medications", meds) + self.assertNotIn("labs", meds) + + def test_differential_matches_the_record(self) -> None: + cds = ce.clinical_decision_support(PATIENT, ["fatigue"]) + top = cds["differential"][0]["condition"].lower() + self.assertIn("diabetes", top) + + def test_appropriate_second_line_is_safe(self) -> None: + check = ce.drug_interaction_check(PATIENT, ["empagliflozin"]) + self.assertFalse(check["has_severe_contraindication"]) + self.assertEqual(check["highest_severity"], "none") + + def test_sulfonamide_allergy_is_a_severe_contraindication(self) -> None: + check = ce.drug_interaction_check(PATIENT, ["co-trimoxazole"]) + self.assertTrue(check["has_severe_contraindication"]) + self.assertEqual(check["contraindications"][0]["type"], "allergy") + + def test_drug_drug_interaction_is_detected(self) -> None: + check = ce.drug_interaction_check(PATIENT, ["potassium chloride"]) + self.assertTrue(any(i["type"] == "drug-drug" for i in check["interactions"])) + self.assertEqual(check["highest_severity"], "severe") + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/healthcare/trace-output/contraindication-trust-record.json b/examples/healthcare/trace-output/contraindication-trust-record.json new file mode 100644 index 00000000..44b39c58 --- /dev/null +++ b/examples/healthcare/trace-output/contraindication-trust-record.json @@ -0,0 +1,104 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1784651780, + "subject": "spiffe://cmcp.gateway/tee/9a013bf66a5aa07d", + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:44ec5f0c1fcdb7c0108dc3e4f73ec2ec73f02705712656cd2bc718ccc72164c4", + "enforcement_mode": "enforce", + "version": "clinical-safety-v3.0" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:8fd7fb6b8abcb83c48b6c3786d55f797a8df5515ebf91e7a868267b08f7b6326", + "call_count": 4, + "entries": [ + { + "tool_name": "ehr.patient_record_lookup", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "ehr.clinical_decision_support", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "ehr.drug_interaction_check", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "ehr.treatment_plan_writer", + "data_class": "confidential", + "decision": "deny" + } + ] + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "mgE79mpaoH14uN7ZMgcitNBkOAy5K9vpQZHwVuu8v8U", + "kid": "cmcp-9a013bf6" + } + } + }, + "gateway": { + "session_id": "1dce4c3e-914f-4210-974d-1f4ff98d11c1", + "gateway_version": "0.3.0", + "sequence_number": 6, + "prev_claim_hash": "sha256:6dcbd4bc631a2b9fe2f328616578cb35101eab6e4fc5f2e69bfead33a1139acd", + "audit_chain": { + "root": "a41e69eb907600e8418f4c6540a527707ea4e2a0ba05a21faec9ecf2eefd3a2b", + "tip": "8fd7fb6b8abcb83c48b6c3786d55f797a8df5515ebf91e7a868267b08f7b6326", + "length": 6 + }, + "call_summary": { + "tool_calls_total": 4, + "tool_calls_allowed": 3, + "tool_calls_denied": 1, + "tool_calls_faulted": 0, + "tools_invoked": [ + "ehr.clinical_decision_support", + "ehr.drug_interaction_check", + "ehr.patient_record_lookup", + "ehr.treatment_plan_writer" + ], + "session_max_sensitivity": "confidential", + "call_graph_summary": { + "compliance_domains_touched": [ + "hipaa_phi" + ], + "cross_boundary_events": [], + "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." + } + }, + "catalog": { + "hash": "sha256:174412ac2254484e3fa65a8c0b29495d5be5e2648e4b3b02398a3f0c9b82f3c7", + "drift_detected": false + }, + "attestation_generated_at": "2026-07-21T16:35:29.936660+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 4, + "tools_called": [ + "ehr.patient_record_lookup", + "ehr.clinical_decision_support", + "ehr.drug_interaction_check", + "ehr.treatment_plan_writer" + ], + "suspicious_sequences_detected": 0 + }, + "kill_switch_triggered": false + }, + "signature": "zdLYpcYSzetbFPhSCjbDjmcPTZcOharv6QNgCN-wtJpeQ2nFM_yDRNiMTGDGkxv5OjCsAGlfuVzVAr2DXITCBA" +} diff --git a/examples/healthcare/trace-output/high-risk-trust-record.json b/examples/healthcare/trace-output/high-risk-trust-record.json new file mode 100644 index 00000000..240049a5 --- /dev/null +++ b/examples/healthcare/trace-output/high-risk-trust-record.json @@ -0,0 +1,104 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1784651776, + "subject": "spiffe://cmcp.gateway/tee/9a013bf66a5aa07d", + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:44ec5f0c1fcdb7c0108dc3e4f73ec2ec73f02705712656cd2bc718ccc72164c4", + "enforcement_mode": "enforce", + "version": "clinical-safety-v3.0" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:8a4059c424b2faba859ab98d7a1f9faadeff9dac30cafb2d650e381a699dd90b", + "call_count": 4, + "entries": [ + { + "tool_name": "ehr.patient_record_lookup", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "ehr.clinical_decision_support", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "ehr.drug_interaction_check", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "ehr.treatment_plan_writer", + "data_class": "confidential", + "decision": "deny" + } + ] + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "mgE79mpaoH14uN7ZMgcitNBkOAy5K9vpQZHwVuu8v8U", + "kid": "cmcp-9a013bf6" + } + } + }, + "gateway": { + "session_id": "8c03dc11-7ef5-49ce-9561-85006c0a4669", + "gateway_version": "0.3.0", + "sequence_number": 5, + "prev_claim_hash": "sha256:6344f83e8397052bbd023f03a3789fa01788ea4f4efca6e6ae56365c66a110b0", + "audit_chain": { + "root": "c4532af6f2eebab2a9b7f47a4f90d099eb3ad21ffca08daa8b1039bf6862fb21", + "tip": "8a4059c424b2faba859ab98d7a1f9faadeff9dac30cafb2d650e381a699dd90b", + "length": 6 + }, + "call_summary": { + "tool_calls_total": 4, + "tool_calls_allowed": 3, + "tool_calls_denied": 1, + "tool_calls_faulted": 0, + "tools_invoked": [ + "ehr.clinical_decision_support", + "ehr.drug_interaction_check", + "ehr.patient_record_lookup", + "ehr.treatment_plan_writer" + ], + "session_max_sensitivity": "confidential", + "call_graph_summary": { + "compliance_domains_touched": [ + "hipaa_phi" + ], + "cross_boundary_events": [], + "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." + } + }, + "catalog": { + "hash": "sha256:174412ac2254484e3fa65a8c0b29495d5be5e2648e4b3b02398a3f0c9b82f3c7", + "drift_detected": false + }, + "attestation_generated_at": "2026-07-21T16:35:29.936660+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 4, + "tools_called": [ + "ehr.patient_record_lookup", + "ehr.clinical_decision_support", + "ehr.drug_interaction_check", + "ehr.treatment_plan_writer" + ], + "suspicious_sequences_detected": 0 + }, + "kill_switch_triggered": false + }, + "signature": "z_O7rgrzXJB0eRmQ9yOWIKjjnUcvB_UCHH3JyUkyXqy-Aum_NBv_r-OH8UDD21QCkuMhNfkW4gTULlO5aaGCCA" +} diff --git a/examples/healthcare/trace-output/standard-trust-record.json b/examples/healthcare/trace-output/standard-trust-record.json new file mode 100644 index 00000000..b17b2b69 --- /dev/null +++ b/examples/healthcare/trace-output/standard-trust-record.json @@ -0,0 +1,104 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1784651772, + "subject": "spiffe://cmcp.gateway/tee/9a013bf66a5aa07d", + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:44ec5f0c1fcdb7c0108dc3e4f73ec2ec73f02705712656cd2bc718ccc72164c4", + "enforcement_mode": "enforce", + "version": "clinical-safety-v3.0" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:b031e6ee9bcdc71747d2faf34c15b1e06ceb1ff5bee87e95ff5dc96d90355e90", + "call_count": 4, + "entries": [ + { + "tool_name": "ehr.patient_record_lookup", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "ehr.clinical_decision_support", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "ehr.drug_interaction_check", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "ehr.treatment_plan_writer", + "data_class": "confidential", + "decision": "allow" + } + ] + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "mgE79mpaoH14uN7ZMgcitNBkOAy5K9vpQZHwVuu8v8U", + "kid": "cmcp-9a013bf6" + } + } + }, + "gateway": { + "session_id": "be8094f5-9939-4995-b0c7-612429626983", + "gateway_version": "0.3.0", + "sequence_number": 4, + "prev_claim_hash": "sha256:185ab93cf14ec485f6cb573c825a40dbb0f9358fc2dd11dfe81ceea6bcc0876b", + "audit_chain": { + "root": "93b80590e0903464c38e3fe002a3bec306052e1933c91a03edeb45486cc5e0e0", + "tip": "b031e6ee9bcdc71747d2faf34c15b1e06ceb1ff5bee87e95ff5dc96d90355e90", + "length": 6 + }, + "call_summary": { + "tool_calls_total": 4, + "tool_calls_allowed": 4, + "tool_calls_denied": 0, + "tool_calls_faulted": 0, + "tools_invoked": [ + "ehr.clinical_decision_support", + "ehr.drug_interaction_check", + "ehr.patient_record_lookup", + "ehr.treatment_plan_writer" + ], + "session_max_sensitivity": "confidential", + "call_graph_summary": { + "compliance_domains_touched": [ + "hipaa_phi" + ], + "cross_boundary_events": [], + "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." + } + }, + "catalog": { + "hash": "sha256:174412ac2254484e3fa65a8c0b29495d5be5e2648e4b3b02398a3f0c9b82f3c7", + "drift_detected": false + }, + "attestation_generated_at": "2026-07-21T16:35:29.936660+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 4, + "tools_called": [ + "ehr.patient_record_lookup", + "ehr.clinical_decision_support", + "ehr.drug_interaction_check", + "ehr.treatment_plan_writer" + ], + "suspicious_sequences_detected": 0 + }, + "kill_switch_triggered": false + }, + "signature": "rxnRxuZVljzM1Juop54J2uCouHtIoP_7RG44PRyWcbWT2hAYa9yo5cPkYSNbVOm3wnr-grLs__yIeuzp6SqoDw" +} diff --git a/examples/healthcare/uk-nhs/README.md b/examples/healthcare/uk-nhs/README.md new file mode 100644 index 00000000..7ade8d75 --- /dev/null +++ b/examples/healthcare/uk-nhs/README.md @@ -0,0 +1,54 @@ +# UK NHS: Clinical AI Governance with TRACE + +Demonstrates TRACE Trust Records for an NHS AI deployment in radiology. Shows how the +governance record maps to MHRA medical device guidance, UK GDPR Article 22 automated +decision-making requirements, and NHS Data Security and Protection Toolkit (DSPT) obligations. + +**Regulatory references:** NHS AI Lab Principles (2023), MHRA Software and AI as a Medical +Device (2024), UK GDPR Article 22, NHS DSPT. + +--- + +## What the Cedar policy enforces + +| Rule | Regulatory basis | What it blocks | +|------|-----------------|---------------| +| Default deny | -- | Anything not explicitly permitted | +| `ukgdpr-art22-clinician-review` | UK GDPR Art. 22 -- significant automated decisions | Reports with `clinical_significance == "significant"` without clinician review token | +| `dspt-required` | NHS DSPT | Any access to NHS patient data without a DSPT access token in context | +| `uk-data-residency` | UK GDPR Chapter V | Calls where `data_residency != "uk-south"` | + +**Key difference from EU AI Act demo:** UK focuses on the DSPT access token as a +runtime enforcement gate (not just configuration), and UK GDPR Art. 22 requires clinician +review for any "significant" AI output rather than EU's risk-category model. UKCA marking +(UK conformity, post-Brexit equivalent of CE) also applies to the device scope. + +--- + +## TRACE Trust Record: key fields for MHRA / NHS audit + +```json +{ + "runtime": { "region": "uk-south", "provider": "azure-confidential-compute" }, + "policy": { "version": "radiology-nhs-v1.0", "enforcement_mode": "enforce" }, + "call_graph_summary": { + "compliance_domains_touched": ["nhs-patient-data", "uk-gdpr-art-22", "nhs-dspt"], + "data_residency_violations": [], + "dspt_token_present": true + } +} +``` + +`dspt_token_present: true` and `data_residency_violations: []` are the two key fields +an NHS Digital or MHRA auditor checks first. + +--- + +## Relationship to other healthcare variants + +| Variant | Jurisdiction | Key differentiator | +|---------|-------------|-------------------| +| Base demo (`../`) | EU + US | EU AI Act Art. 14 + HIPAA | +| `../us-fda-samd/` | US FDA | Cleared-scope enforcement, SaMD Action Plan | +| This demo | UK | UK GDPR Art. 22, DSPT token gate, MHRA oversight | +| `../sg-moh/` | Singapore | IMDA Tier 1/2, PDPA consent, MOH guidelines | diff --git a/examples/healthcare/uk-nhs/policy/radiology-nhs.cedar b/examples/healthcare/uk-nhs/policy/radiology-nhs.cedar new file mode 100644 index 00000000..1e39a7a0 --- /dev/null +++ b/examples/healthcare/uk-nhs/policy/radiology-nhs.cedar @@ -0,0 +1,86 @@ +// Cedar policy bundle for NHS AI deployment in radiology +// version: radiology-nhs-v1.0 +// Regulatory references: +// NHS AI Lab: Principles for the Development, Deployment and Use of AI in Health (2023) +// MHRA guidance: Software and AI as a Medical Device (2024) +// NHS Data Security and Protection Toolkit (DSPT) -- mandatory for NHS data access +// UK GDPR Article 22: automated decision-making with significant effects +// +// Cedar default-deny. workflow_id required on every call. + +// Rule 1: imaging read permitted for the nhs-radiology-ai workflow +// only when the DSPT access agreement token is present. +permit ( + principal, + action == Action::"Radiology.imagingRead", + resource +) when { + context has workflow_id && + context.workflow_id == "nhs-radiology-ai" && + context has dspt_access_token && + context.dspt_access_token != "" +}; + +// Rule 2: inference permitted within the UKCA-marked device scope. +permit ( + principal, + action == Action::"Radiology.runDiagnosticInference", + resource +) when { + context has workflow_id && + context.workflow_id == "nhs-radiology-ai" +}; + +// Rule 3: diagnostic report write permitted, subject to MHRA human oversight below. +permit ( + principal, + action == Action::"Radiology.writeDiagnosticReport", + resource +) when { + context has workflow_id && + context.workflow_id == "nhs-radiology-ai" +}; + +// Rule 4: UK GDPR Article 22 -- AI decisions with significant effects on a patient +// require a qualified clinician to review before the report is issued. +// MHRA: clinically significant AI outputs must remain under qualified human oversight. +@id("ukgdpr-art22-clinician-review") +@reason("clinician-review-required") +@regulation("uk-gdpr-art-22") +@reviewer_role("reporting-radiographer-or-radiologist") +forbid ( + principal, + action == Action::"Radiology.writeDiagnosticReport", + resource +) when { + context.arguments has clinical_significance && + context.arguments.clinical_significance == "significant" +}; + +// Rule 5: NHS data access requires a valid DSPT token; deny if absent. +// DSPT is a mandatory NHS England framework for data security. +@id("dspt-required") +@reason("nhs-dspt-access-agreement-required") +@regulation("nhs-dspt") +forbid ( + principal, + action, + resource +) when { + context.data_class == "nhs-patient-data" && + !(context has dspt_access_token) +}; + +// Rule 6: patient data must remain within UK geographic boundary. +// UK GDPR Chapter V: international transfers require adequacy decision or SCCs. +@id("uk-data-residency") +@reason("uk-data-residency-required") +@regulation("uk-gdpr-chapter-v") +forbid ( + principal, + action, + resource +) when { + context has data_residency && + context.data_residency != "uk-south" +}; diff --git a/examples/healthcare/uk-nhs/trace-output/example-nhs-trust-record.json b/examples/healthcare/uk-nhs/trace-output/example-nhs-trust-record.json new file mode 100644 index 00000000..3b75d8bf --- /dev/null +++ b/examples/healthcare/uk-nhs/trace-output/example-nhs-trust-record.json @@ -0,0 +1,59 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1781280200, + "subject": "spiffe://cmcp.gateway/session/uk-nhs-demo-session-001", + "runtime": { + "platform": "intel-tdx", + "measurement": "sha256:c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + "firmware_version": "intel-tdx-1.5.0", + "region": "uk-south", + "provider": "azure-confidential-compute" + }, + "policy": { + "bundle_hash": "sha256:e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8", + "enforcement_mode": "enforce", + "version": "radiology-nhs-v1.0" + }, + "data_class": "nhs-patient-data", + "tool_transcript": { + "hash": "sha256:a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + "call_count": 3 + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "k5b2YtKvWY2FwTDnV8SV194FPh4loN3FwkjRt2b2y47", + "kid": "cmcp-uk-nhs-01" + } + } + }, + "gateway": { + "session_id": "uk-nhs-demo-session-001", + "gateway_version": "1.0.0", + "sequence_number": 3, + "audit_chain": { + "root": "c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4", + "tip": "a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0", + "length": 5 + }, + "call_summary": { + "tool_calls_total": 3, + "tool_calls_allowed": 3, + "tool_calls_denied": 0, + "tools_invoked": [ + "radiology.imaging_read", + "radiology.run_diagnostic_inference", + "radiology.write_diagnostic_report" + ], + "session_max_sensitivity": "nhs-patient-data", + "call_graph_summary": { + "compliance_domains_touched": ["nhs-patient-data", "uk-gdpr-art-22", "nhs-dspt"], + "data_residency_violations": [], + "dspt_token_present": true + } + } + } +} diff --git a/examples/healthcare/us-fda-samd/README.md b/examples/healthcare/us-fda-samd/README.md new file mode 100644 index 00000000..c9a51d73 --- /dev/null +++ b/examples/healthcare/us-fda-samd/README.md @@ -0,0 +1,52 @@ +# US FDA SaMD: Clinical AI Governance with TRACE + +Demonstrates TRACE Trust Records for an AI/ML-based Software as a Medical Device (SaMD) +running in a US regulated context. Shows how the governance record maps to FDA SaMD Action +Plan requirements, cleared-scope enforcement, and HIPAA PHI safeguards. + +**Regulatory references:** FDA AI/ML SaMD Action Plan (2021), 21 CFR Part 820, HIPAA 45 CFR 164.312. + +--- + +## What the Cedar policy enforces + +| Rule | Regulatory basis | What it blocks | +|------|-----------------|---------------| +| Default deny | -- | Anything not explicitly permitted | +| `hitl-high-acuity` | FDA SaMD Action Plan -- human-AI teaming | Autonomous writes of critical-acuity reports without physician sign-off | +| `out-of-scope-modality` | 21 CFR Part 820 -- cleared device scope | Inference on imaging types outside the cleared indication of use | +| `require-attested-runtime` | HIPAA 164.312 | PHI tool access when `attestation_platform == "unknown"` | + +**Key difference from EU AI Act demo:** US focuses on cleared-scope enforcement (the SaMD +may only run on imaging modalities it was FDA-cleared for) and real-world evidence +traceability, rather than EU's risk-category human oversight model. + +--- + +## TRACE Trust Record: key fields for FDA audit + +```json +{ + "runtime": { "region": "us-east-1", "provider": "aws-nitro-enclaves" }, + "policy": { "version": "radiology-fda-v1.0", "enforcement_mode": "enforce" }, + "call_graph_summary": { + "compliance_domains_touched": ["phi", "hipaa-164-312", "fda-samd-action-plan-2021"], + "cleared_scope_violations": [], + "imaging_modality_used": "chest-xr" + } +} +``` + +`cleared_scope_violations: []` is the machine-readable answer to "did the SaMD operate +outside its FDA-cleared scope?" for this session. + +--- + +## Relationship to other healthcare variants + +| Variant | Jurisdiction | Key differentiator | +|---------|-------------|-------------------| +| Base demo (`../`) | EU + US | EU AI Act Art. 14 + HIPAA | +| This demo | US FDA | Cleared-scope enforcement, SaMD Action Plan | +| `../uk-nhs/` | UK | UK GDPR Art. 22, DSPT token, MHRA oversight | +| `../sg-moh/` | Singapore | IMDA Tier 1/2, PDPA consent, MOH guidelines | diff --git a/examples/healthcare/us-fda-samd/policy/radiology-fda.cedar b/examples/healthcare/us-fda-samd/policy/radiology-fda.cedar new file mode 100644 index 00000000..5c2eb470 --- /dev/null +++ b/examples/healthcare/us-fda-samd/policy/radiology-fda.cedar @@ -0,0 +1,86 @@ +// Cedar policy bundle for AI/ML-based Software as a Medical Device (SaMD) +// version: radiology-fda-v1.0 +// Regulatory references: +// FDA Guidance: Artificial Intelligence/Machine Learning-Based SaMD Action Plan (2021) +// FDA 21 CFR Part 820 (Quality System Regulation) +// 21st Century Cures Act - Real-World Evidence requirements +// HIPAA 45 CFR Parts 160 and 164 (PHI safeguards) +// +// Cedar default-deny. workflow_id required on every call. + +// Rule 1: imaging read permitted for the radiology-samd workflow. +permit ( + principal, + action == Action::"Radiology.imagingRead", + resource +) when { + context has workflow_id && + context.workflow_id == "radiology-samd" +}; + +// Rule 2: AI inference permitted within the cleared indication of use. +// FDA SaMD: model may only run on imaging types within its 510(k)/DeNovo cleared scope. +permit ( + principal, + action == Action::"Radiology.runDiagnosticInference", + resource +) when { + context has workflow_id && + context.workflow_id == "radiology-samd" && + context has imaging_modality && + ["chest-xr", "ct-thorax", "mammography"].contains(context.imaging_modality) +}; + +// Rule 3: diagnostic report write permitted, subject to human oversight rules below. +permit ( + principal, + action == Action::"Radiology.writeDiagnosticReport", + resource +) when { + context has workflow_id && + context.workflow_id == "radiology-samd" +}; + +// Rule 4: FDA SaMD -- autonomous AI decisions on high-acuity findings require physician +// sign-off before the report is finalized. Maps to FDA guidance on human-AI teaming +// and the "Software Function" oversight category. +@id("hitl-high-acuity") +@reason("physician-review-required") +@regulation("fda-samd-action-plan-2021") +@reviewer_role("radiologist-md") +forbid ( + principal, + action == Action::"Radiology.writeDiagnosticReport", + resource +) when { + context.arguments has acuity_level && + context.arguments.acuity_level == "critical" +}; + +// Rule 5: inference outside the cleared imaging modality scope is blocked. +// FDA: use outside cleared indications is an unauthorized device modification. +@id("out-of-scope-modality") +@reason("imaging-modality-outside-cleared-scope") +@regulation("fda-21-cfr-820") +forbid ( + principal, + action == Action::"Radiology.runDiagnosticInference", + resource +) when { + context has imaging_modality && + !["chest-xr", "ct-thorax", "mammography"].contains(context.imaging_modality) +}; + +// Rule 6: PHI may only flow through an attested runtime. +// HIPAA 45 CFR 164.312(a)(2)(iv): encryption and decryption of PHI. +@id("require-attested-runtime") +@reason("attested-runtime-required") +@regulation("hipaa-164-312") +forbid ( + principal, + action, + resource +) when { + context.data_class == "phi" && + context.attestation_platform == "unknown" +}; diff --git a/examples/healthcare/us-fda-samd/trace-output/example-fda-trust-record.json b/examples/healthcare/us-fda-samd/trace-output/example-fda-trust-record.json new file mode 100644 index 00000000..09a6703b --- /dev/null +++ b/examples/healthcare/us-fda-samd/trace-output/example-fda-trust-record.json @@ -0,0 +1,59 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1781280100, + "subject": "spiffe://cmcp.gateway/session/us-fda-samd-demo-session-001", + "runtime": { + "platform": "amd-sev-snp", + "measurement": "sha256:b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5", + "firmware_version": "amd-sev-snp-milan-1.55.21", + "region": "us-east-1", + "provider": "aws-nitro-enclaves" + }, + "policy": { + "bundle_hash": "sha256:d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7", + "enforcement_mode": "enforce", + "version": "radiology-fda-v1.0" + }, + "data_class": "phi", + "tool_transcript": { + "hash": "sha256:f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9", + "call_count": 3 + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "j4a1XsJuVX1EvSCmU7RU093EOg3knM2EvjkQs1a1x36", + "kid": "cmcp-us-fda-samd-01" + } + } + }, + "gateway": { + "session_id": "us-fda-samd-demo-session-001", + "gateway_version": "1.0.0", + "sequence_number": 3, + "audit_chain": { + "root": "b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3", + "tip": "f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9", + "length": 5 + }, + "call_summary": { + "tool_calls_total": 3, + "tool_calls_allowed": 3, + "tool_calls_denied": 0, + "tools_invoked": [ + "radiology.imaging_read", + "radiology.run_diagnostic_inference", + "radiology.write_diagnostic_report" + ], + "session_max_sensitivity": "phi", + "call_graph_summary": { + "compliance_domains_touched": ["phi", "hipaa-164-312", "fda-samd-action-plan-2021"], + "cleared_scope_violations": [], + "imaging_modality_used": "chest-xr" + } + } + } +} diff --git a/examples/industrial-embodied-ai/.gitattributes b/examples/industrial-embodied-ai/.gitattributes new file mode 100644 index 00000000..454aa455 --- /dev/null +++ b/examples/industrial-embodied-ai/.gitattributes @@ -0,0 +1,6 @@ +# Hash-bound artifacts must check out byte-identical on every platform. +# core.autocrlf=true otherwise rewrites LF to CRLF on Windows and breaks +# the system_prompt_hash check in validate_artifacts.py. +artifacts/** -text +artifact-hashes.json -text +trace-output/*.json -text diff --git a/examples/industrial-embodied-ai/.gitignore b/examples/industrial-embodied-ai/.gitignore new file mode 100644 index 00000000..652dd802 --- /dev/null +++ b/examples/industrial-embodied-ai/.gitignore @@ -0,0 +1,5 @@ +__pycache__/ +*.pyc +.venv/ +audit.db +trace-output/latest-*.json diff --git a/examples/industrial-embodied-ai/README.md b/examples/industrial-embodied-ai/README.md new file mode 100644 index 00000000..ccfb320d --- /dev/null +++ b/examples/industrial-embodied-ai/README.md @@ -0,0 +1,356 @@ +# industrial-embodied-ai: Governed Material Movement + +End-to-end example of an AI agent requesting motion from an industrial robot +cell through cMCP, with Agent Manifest declarations and a runtime-issued TRACE +Trust Record. + +The example also demonstrates evidence continuity: after the governed session +closes, its saved TRACE Trust Record and audit bundle remain verifiable after +the agent, cMCP Runtime and mock controller stop. This is continuity of +evidence, not continuity of agent memory, reputation or process identity. +Live runs also attach a controller-signed `external_execution_evidence` +receipt to each motion decision when run with a cMCP Runtime that supports +issue #301 external evidence binding. + +The scenario is synthetic. It uses no robot hardware, vendor SDK, production +endpoint, or proprietary industrial data. + +## What the example demonstrates + +The agent runs three paths through a live cMCP Runtime, then closes the session +to produce durable evidence: + +1. **Allowed and completed:** cMCP authorizes the declared workflow, then the + independent controller accepts and completes the simulated motion. +2. **Scope denied, the same motion out of scope:** the agent requests the same + in-envelope motion as the authorized path (the same approved zone and + in-limit speed, a fresh valid safety token) but under an undeclared workflow. + cMCP denies it on scope before the controller is consulted, so the safety + token is never checked. The only difference from the authorized path is the + workflow scope, not the motion or its safety: the trust layer withholds the + action because it is outside the agent's declared purpose. +3. **Safety rejected:** cMCP authorizes the declared workflow, but the + controller rejects motion after its current state reports a person in the + safeguarded area. +4. **Controller-signed execution receipts:** each motion decision includes a + mock controller receipt that cMCP can bind to the audit entry for that + `call_id`. +5. **Closed-session evidence:** cMCP signs a TRACE Trust Record and audit + bundle that can be verified from the saved files without a running agent, + runtime or controller. + +Two boundaries sit at the center of this example, and they point in opposite +directions: + +> A cMCP `allow` decision means the software request is authorized. It does +> not mean that a physical action is safe, accepted by the controller, or +> completed by a machine. + +> A controller `accept` decision means a motion is inside the safety envelope. +> It does not mean the action was authorized, in declared scope, or issued by +> the agent that was reviewed. The safety controller has no concept of +> workflow, declared purpose, or agent identity, so it cannot answer those +> questions. The trust layer is what answers them. + +The third path shows the first boundary: an authorized request the controller +still refuses. The second path shows the second boundary: an in-envelope motion +the trust layer refuses on scope. Neither layer subsumes the other, and an +individually safe motion is not, by itself, a trusted one. + +## Architecture + +```text + Signed Agent Manifest Material-movement agent + - agent identity declaration | + - prompt, policy and tool hashes | MCP tools/call + | v + | offline validator +--------------------------+ + +-- compares hashes ------> | cMCP Runtime | + | - loads policy + catalog | + | - Cedar authorization | + | - hash-chained audit | + +------------+-------------+ + | + | authorized request + v + +-------------------------------+ + | Independent mock controller | + | - validates fresh state token | + | - rechecks current cell state | + | - enforces speed and zone | + | - signs motion decisions | + +---------------+---------------+ + | + | accepted command + v + Simulated robot execution + + Session close -> signed TRACE Trust Record + signed audit bundle + Saved files remain verifiable after all three processes stop +``` + +The current cMCP preview loads the policy and catalog directly. It does not +ingest the Agent Manifest, so the diagram shows an offline hash comparison +rather than a native runtime binding. + +## Trust chain demonstrated + +| Boundary | Demonstrated behavior | +|---|---| +| Declared agent configuration | A signed Agent Manifest declares the development agent identity and hashes for its prompt, policy and tools | +| Governed tool access | cMCP intercepts each MCP request and evaluates the active Cedar policy before forwarding | +| Scope over safety | cMCP denies an in-envelope motion that falls outside the declared workflow, a check the safety controller does not perform | +| Physical authority | The independent controller rechecks current state and remains authoritative for simulated execution | +| External controller evidence | Motion outcomes can include controller-signed receipts bound to the cMCP audit call id | +| Durable session evidence | TRACE and the signed audit bundle bind the cMCP session, policy, catalog and tool-call transcript | + +The example composes these boundaries without claiming that the developer +preview already forms one end-to-end identity and outcome proof. The precise +gaps are listed under [Evidence boundaries](#evidence-boundaries). + +## Why individually safe motions still need trust + +A safety controller answers one question per motion: is this movement, right +now, inside the envelope. It cannot answer whether a long run of individually +safe motions adds up to something the operator never authorized: an agent that +quietly skips an inspection step, drifts from its declared task, or runs a +configuration that no longer matches the one that was reviewed. Every motion +passes. The harm is in the pattern, not in any single move, and no safety +controller is built to see it. + +That is why the durable evidence matters as much as the live decision. The +signed TRACE record and audit bundle let a later reader, an auditor or an +insurer who has no reason to trust the operator's database, reconstruct which +agent configuration acted, under which policy, and against which declared scope, +across the whole session. The trust layer is not a second safety check. It +answers a different question, on a different clock, that physical safety alone +cannot. The controller-side of this boundary is pinned by +`test_safe_motion_is_not_proof_of_authorization` in `tests/test_controller.py`: +a motion the controller accepts as safe carries no claim about scope, purpose +or agent identity. + +## Run it + +Prerequisites: + +- Python 3.11 or newer +- Git + +The project is a developer preview. `requirements.txt` pins the TRACE commit +and a cMCP external-evidence PR commit used for this reproducible example +until the summit release stack is available from PyPI. + +```bash +cd industrial-embodied-ai +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +``` + +Start the mock controller: + +```bash +python server/mock_robot_controller.py +``` + +In a second terminal, start cMCP in explicitly non-hardware development mode: + +```bash +cd industrial-embodied-ai +source .venv/bin/activate +CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml +``` + +In a third terminal, run the agent: + +```bash +cd industrial-embodied-ai +source .venv/bin/activate +python agent/material_movement_agent.py +``` + +Expected summary: + +```text +SCOPE DENY + cMCP policy: denied (out of declared scope) + controller: not consulted + +SUCCESS + cMCP policy: authorized + controller: accepted + execution: completed + +SAFETY REJECT + cMCP policy: authorized + controller: rejected + reason: human_detected + execution: not_started + +TRACE VERIFICATION + schema/signature/hashes/freshness: verified + audit bundle: verified + controller receipts: verified (2) + runtime platform: software-only + hardware attestation: not verified (development mode) +``` + +The agent writes fresh evidence to: + +- `trace-output/latest-trust-record.json` +- `trace-output/latest-audit-bundle.json` + +These files are ignored by Git. The committed `example-*` files were captured +from a real run and remain available for offline inspection. + +## Verify after shutdown + +After the agent exits, stop the cMCP Runtime and mock controller. The fresh +files can still be verified locally: + +```bash +cmcp verify trace-output/latest-trust-record.json \ + --policy-hash sha256:c8358148d201749ebd05651ea03cf92fb3ff8cc9cf05816483c394ebc3e1cac9 \ + --catalog-hash sha256:792c86ff8152fa9713d52584c084611eb4929fa5ebf3ec8271dd21f0e0aa7eeb \ + --audit-bundle trace-output/latest-audit-bundle.json +``` + +This command reads the saved evidence and does not contact the stopped +services. The normal attestation-freshness window still applies: offline +verification means that no live runtime is required, not that an old claim +remains current indefinitely. + +## Hardware-attested run + +On a supported host, do not set `CMCP_DEV_MODE`. Pin the expected artifacts +and configure the same bearer token for the runtime and agent: + +```bash +export CMCP_BEARER_TOKEN="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')" +export CMCP_POLICY_HASH="sha256:c8358148d201749ebd05651ea03cf92fb3ff8cc9cf05816483c394ebc3e1cac9" +export CMCP_CATALOG_HASH="sha256:792c86ff8152fa9713d52584c084611eb4929fa5ebf3ec8271dd21f0e0aa7eeb" + +cmcp start --config cmcp-config.yaml +``` + +Then require hardware verification in the agent: + +```bash +python agent/material_movement_agent.py --require-hardware +``` + +The runtime must detect and successfully verify one of its supported +attestation providers. Development mode intentionally cannot satisfy +`--require-hardware`. + +## Verify committed artifacts + +```bash +python validate_artifacts.py +python -m unittest discover -s tests -v +``` + +The validator checks: + +- cMCP configuration and catalog definition hashes +- policy bundle and catalog hashes +- Agent Manifest artifact bindings and Ed25519 signature +- runtime-issued TRACE schema and signature +- signed audit-bundle integrity and binding to the TRACE record +- controller execution receipts when present in the audit bundle + +## Evidence boundaries + +| Evidence | What it establishes | What it does not establish | +|---|---|---| +| Agent Manifest | The signed agent identity declaration and hashes of the approved prompt, policy and tools | That cMCP loaded the manifest or bound its agent identity to the runtime session | +| cMCP decision | The active policy authorized or denied a cataloged tool request | That an authorized physical request was safe | +| Controller accept | The motion was inside the safety envelope for this run | That the action was authorized, in declared scope, or issued by the reviewed agent | +| Controller execution receipt | The mock controller signed a specific accepted or rejected motion decision and linked it to a cMCP audit `call_id` | Hardware-backed execution, physical completion, or functional-safety compliance | +| TRACE Trust Record | cMCP session identity, runtime, policy hash, catalog hash and tool-call transcript integrity | The Agent Manifest identity, controller acceptance, physical completion or functional-safety compliance | +| Saved TRACE and audit files | The closed session can be checked after the processes stop | Continuity of agent memory, reputation or logical identity across a restart or replacement | + +When run against a cMCP build with issue #301 support, the audit bundle records +the response hash and can bind the mock controller's signed receipt for each +motion decision. The receipt proves the mock controller signed a decision for +that call id. It does not claim that TRACE proves physical completion, +hardware-backed execution, or compliance with industrial safety standards. + +The committed TRACE subject identifies the cMCP session, while the Agent +Manifest declares a separate agent identity. The validator confirms that the +static policy and catalog hashes agree, but the current preview does not +cryptographically bind that manifest identity to the runtime session. It also +does not establish that a restarted or replacement process is the same logical +agent. + +## AgenTrust artifacts + +| File | Purpose | +|---|---| +| `agent-manifest.json` | Signed development declaration binding the prompt, policy bundle and tool catalog | +| `manifest-public-key.json` | Public verification key for the Agent Manifest | +| `artifact-hashes.json` | Approved cMCP and Agent Manifest artifact hashes | +| `controller-receipt-public-key.json` | Development-only public key for verifying mock controller receipts | +| `catalog.json` | Attested definitions for safety-state and motion-request tools | +| `cmcp-config.yaml` | cMCP configuration shared by development and hardware runs | +| `policy/allow.cedar` | Explicit workflow-scoped permits with default deny | +| `trace-output/example-trust-record.json` | TRACE Trust Record captured from the live development run | +| `trace-output/example-audit-bundle.json` | Signed cMCP audit bundle captured from the same session | + +The Agent Manifest is a signed declaration artifact in this example. The +current cMCP preview loads the policy and catalog directly; the validator +checks that their hashes agree with the manifest instead of claiming cMCP +consumes the manifest natively. + +## Threats illustrated + +- **Compromised planner:** only cataloged tools in the declared workflow are + authorized. +- **Policy bypass:** the undeclared workflow is denied before forwarding. +- **Untrusted request arguments:** the controller never accepts agent-supplied + safety booleans. +- **Stale or modified state:** short-lived authenticated state tokens fail + closed. +- **Replay:** a state token is single-use. +- **Time-of-check/time-of-use change:** the controller rechecks current state + and rejects motion even after cMCP authorization. + +The HMAC state token is a teaching mechanism, not an industrial security +protocol. A real deployment requires authenticated device identity, protected +keys, replay protection, secure time and a validated industrial communication +architecture. + +## Safety boundary + +This example does not provide or certify: + +- emergency-stop functions +- protective stops or safe torque off +- collision avoidance +- safe speed or separation monitoring +- safety-rated control logic +- machinery conformity assessment + +Those functions remain in independently engineered and validated +safety-related control systems. + +## External references + +- [NIST SP 800-82 Rev. 3](https://csrc.nist.gov/pubs/sp/800/82/r3/final) +- [ISO 10218-1:2025](https://www.iso.org/standard/73933.html) +- [ISO 10218-2:2025](https://www.iso.org/standard/73934.html) +- [ISO 13849-1:2023](https://www.iso.org/standard/73481.html) +- [IEC 62443-4-2:2019](https://webstore.iec.ch/en/publication/34421) +- [IETF RFC 9334](https://datatracker.ietf.org/doc/rfc9334/) +- [IETF RFC 9711](https://datatracker.ietf.org/doc/rfc9711/) +- [ROS 2 Security Enclaves](https://design.ros2.org/articles/ros2_security_enclaves.html) +- [ROS 2 Access Control Policies](https://design.ros2.org/articles/ros2_access_control_policies.html) +- [Regulation (EU) 2023/1230](https://eur-lex.europa.eu/eli/reg/2023/1230/oj/eng) +- [Regulation (EU) 2024/1689](https://eur-lex.europa.eu/eli/reg/2024/1689/oj/eng) + +These references inform the separation of responsibilities. The example is +not a claim of compliance with any standard or regulation. + +## License + +Apache 2.0. See [LICENSE](../LICENSE) in the repository root. diff --git a/examples/industrial-embodied-ai/agent-manifest.json b/examples/industrial-embodied-ai/agent-manifest.json new file mode 100644 index 00000000..08aa8069 --- /dev/null +++ b/examples/industrial-embodied-ai/agent-manifest.json @@ -0,0 +1,88 @@ +{ + "@context": "https://agentmanifest.agentrust.io/v0.1/context.json", + "@type": "AgentManifest", + "manifest_id": "0197739a-8c00-7000-8000-000000000001", + "agent_id": "spiffe://factory.example/agent/material-movement/dev", + "version": "0.1", + "issued_at": "2026-06-12T00:00:00Z", + "expires_at": "2026-09-10T00:00:00Z", + "issuer": "spiffe://factory.example/signing-authority/development", + "crypto_profile": "standard", + "artifacts": { + "system_prompt": { + "hash": "sha256:9b364864a1d6682f56f572052dd7a0701b35d9726046bcb06d5fa5d87b49a70f", + "hash_algorithm": "SHA-256", + "version": "0.1.0", + "classification": "internal", + "bound_at": "2026-06-12T00:00:00Z" + }, + "policy_bundle": { + "hash": "sha256:c8358148d201749ebd05651ea03cf92fb3ff8cc9cf05816483c394ebc3e1cac9", + "policy_language": "cedar", + "version": "0.1.0", + "enforcement_mode": "enforce", + "scope": [ + "cell.read_safety_state", + "robot.request_motion" + ], + "bound_at": "2026-06-12T00:00:00Z" + }, + "tool_manifest": { + "catalog_hash": "sha256:3c7460301bbf38612b492de4a2095ccf40b7d921bdc9ce96e8ea0f50068d66e0", + "tools": [ + { + "tool_id": "spiffe://factory.example/tool/cell.read_safety_state", + "name": "cell.read_safety_state", + "server_id": "spiffe://factory.example/controller/robot-cell-7", + "schema_hash": "sha256:99334726611ccf58a148b0814696bfa6fe08c1b2d027e946beccf5a74331c9aa", + "description_hash": "sha256:1a4030f710f75b6d7cabddbde5a774ca541450f7a8dcb5f46fcbf9a119dd6de7", + "version": "0.1.0", + "permission_scope": "safety-state:read", + "egress_destinations": [] + }, + { + "tool_id": "spiffe://factory.example/tool/robot.request_motion", + "name": "robot.request_motion", + "server_id": "spiffe://factory.example/controller/robot-cell-7", + "schema_hash": "sha256:d74b377cfce397a9074d2b6b377615f32f370e218a59594346cbaadd8ca88d00", + "description_hash": "sha256:6b67d52c678da09fc6bce54367ba3bf0a44ff92f3930db8a66e3d43af7ab6975", + "version": "0.1.0", + "permission_scope": "motion:request", + "egress_destinations": [] + } + ], + "allow_dynamic_registration": false, + "rug_pull_policy": "deny-and-alert", + "bound_at": "2026-06-12T00:00:00Z" + }, + "model_identity": { + "provider": "example", + "model_id": "industrial-material-movement-planner", + "version": "development-simulation-v1", + "deployment_type": "api", + "bound_at": "2026-06-12T00:00:00Z" + } + }, + "delegation_chain": [], + "signature": { + "algorithm": "Ed25519", + "key_id": "cd3708166771d7bd74a49e8d307842c318aded09c0348688d7ac6e40952a5522", + "key_type": "software", + "signed_at": "2026-06-12T00:00:00Z", + "signed_fields": [ + "@context", + "@type", + "manifest_id", + "agent_id", + "version", + "issued_at", + "expires_at", + "issuer", + "crypto_profile", + "artifacts", + "delegation_chain", + "hitl_record" + ], + "signature_value": "zYvzfGDshwKcAApc_JWmCXigNytQDhokavCQCTUwOtsQeDLYEoPETxgii3Acz2hl7RNIH-O4FKTdtYlNXt6MBA" + } +} diff --git a/examples/industrial-embodied-ai/agent/material_movement_agent.py b/examples/industrial-embodied-ai/agent/material_movement_agent.py new file mode 100644 index 00000000..9e8a4ba6 --- /dev/null +++ b/examples/industrial-embodied-ai/agent/material_movement_agent.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""Run the industrial material-movement scenarios through a live cMCP Runtime.""" + +from __future__ import annotations + +import argparse +import base64 +import json +import os +from pathlib import Path +from typing import Any + +import httpx + +from cmcp_verify import ApprovedHashes, verify_audit_bundle, verify_trace_claim + + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +DEFAULT_GATEWAY = "http://localhost:8443" +WORKFLOW_ID = "industrial-material-movement" + + +def _b64url_decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _external_evidence_keys() -> dict[str, bytes]: + key_doc = json.loads( + (EXAMPLE_DIR / "controller-receipt-public-key.json").read_text() + ) + key_id = key_doc["key_id"] + return {key_id: _b64url_decode(key_doc["public_key_base64url"])} + + +def _headers() -> dict[str, str]: + headers = {"Content-Type": "application/json"} + if token := os.environ.get("CMCP_BEARER_TOKEN"): + headers["Authorization"] = f"Bearer {token}" + return headers + + +def call_tool( + client: httpx.Client, + gateway: str, + tool_name: str, + arguments: dict[str, Any], + request_id: int, + *, + workflow_id: str = WORKFLOW_ID, +) -> dict[str, Any]: + response = client.post( + f"{gateway}/mcp", + json={ + "jsonrpc": "2.0", + "id": request_id, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments, + "_cmcp": {"workflow_id": workflow_id}, + }, + }, + timeout=30, + ) + body = response.json() + if "error" in body: + return { + "ok": False, + "status_code": response.status_code, + "error": body["error"], + } + + result = body["result"] + text = result.get("content", [{}])[0].get("text", "{}") + return { + "ok": True, + "payload": json.loads(text), + "session_id": result.get("_cmcp", {}).get("session_id"), + "cmcp": result.get("_cmcp", {}), + } + + +def _read_state( + client: httpx.Client, + gateway: str, + request_id: int, +) -> dict[str, Any]: + result = call_tool( + client, + gateway, + "cell.read_safety_state", + {}, + request_id, + ) + if not result["ok"]: + raise RuntimeError(f"Safety-state read failed: {result['error']}") + return result + + +def _request_motion( + client: httpx.Client, + gateway: str, + request_id: int, + snapshot: dict[str, Any], + motion_id: str, + *, + workflow_id: str = WORKFLOW_ID, +) -> dict[str, Any]: + return call_tool( + client, + gateway, + "robot.request_motion", + { + "motion_id": motion_id, + "target": "transfer-station-b", + "max_speed_mps": 0.3, + "safety_state_token": snapshot["state_token"], + }, + request_id, + workflow_id=workflow_id, + ) + + +def _save_json(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2) + "\n") + + +def _verify_evidence( + claim: dict[str, Any], + bundle: dict[str, Any], + *, + require_hardware: bool, +) -> None: + expected = json.loads((EXAMPLE_DIR / "artifact-hashes.json").read_text()) + result = verify_trace_claim( + claim, + ApprovedHashes( + policy_bundle_hash=expected["cmcp_policy_bundle_hash"], + tool_catalog_hash=expected["cmcp_catalog_hash"], + ), + ) + bundle_result = verify_audit_bundle( + bundle, + claim, + external_evidence_keys=_external_evidence_keys(), + ) + required = { + "schema", + "signature", + "policy_bundle.hash", + "tool_catalog.hash", + "attestation_freshness", + "audit_chain", + } + missing = sorted(required - set(result.verified_fields)) + + print("TRACE VERIFICATION") + schema_status = "verified" if not missing else "failed" + print(f" schema/signature/hashes/freshness: {schema_status}") + print(f" audit bundle: {'verified' if bundle_result.verified else 'failed'}") + receipt_count = sum( + 1 + for entry in bundle.get("entries", []) + if entry.get("external_execution_evidence") + ) + if receipt_count: + print(f" controller receipts: verified ({receipt_count})") + else: + print(" controller receipts: not present in this bundle") + platform = claim.get("trace", {}).get("runtime", {}).get("platform", "unknown") + print(f" runtime platform: {platform}") + if platform == "software-only": + print(" hardware attestation: not verified (development mode)") + if require_hardware: + missing.append("hardware_attestation") + else: + hardware_verified = "hardware_attestation" in result.verified_fields + hardware_status = "verified" if hardware_verified else "failed" + print(f" hardware attestation: {hardware_status}") + if require_hardware and not hardware_verified: + missing.append("hardware_attestation") + + if bundle_result.failures: + print(f" audit failures: {'; '.join(bundle_result.failures)}") + if missing or not bundle_result.verified: + raise RuntimeError( + "Evidence verification failed: " + + ", ".join(missing or bundle_result.failures) + ) + + +def run( + gateway: str, + claim_out: Path, + audit_out: Path, + *, + require_hardware: bool, + print_claim: bool, +) -> None: + session_id: str | None = None + with httpx.Client(headers=_headers()) as client: + print("SCOPE DENY") + # The same in-envelope motion as the authorized path below (an approved + # zone, an in-limit speed, a fresh valid safety token), requested under + # an undeclared workflow. cMCP denies it on scope before the controller + # is consulted, so the safety token is never even checked. The only + # difference from the authorized path is the workflow scope, not the + # motion or its physical safety. + state = _read_state(client, gateway, 1) + session_id = state["session_id"] + denied = _request_motion( + client, + gateway, + 2, + state["payload"], + "move-0001", + workflow_id="unapproved-diagnostics", + ) + if denied["ok"] or denied["status_code"] != 403: + raise RuntimeError("Out-of-scope motion was not denied by cMCP") + print(" cMCP policy: denied (out of declared scope)") + print(" controller: not consulted") + print() + + print("SUCCESS") + # The identical motion, now under the declared workflow. cMCP authorizes + # it and the independent controller accepts and completes it. + state = _read_state(client, gateway, 3) + success = _request_motion( + client, + gateway, + 4, + state["payload"], + "move-0002", + ) + if not success["ok"]: + raise RuntimeError(f"Success path failed: {success['error']}") + if ( + success["payload"].get("controller_decision") != "accepted" + or success["payload"].get("execution_status") != "completed" + ): + raise RuntimeError("Controller did not complete the expected safe motion") + print(" cMCP policy: authorized") + print(f" controller: {success['payload']['controller_decision']}") + print(f" execution: {success['payload']['execution_status']}") + print() + + print("SAFETY REJECT") + state = _read_state(client, gateway, 5) + rejected = _request_motion( + client, + gateway, + 6, + state["payload"], + "move-0003", + ) + if not rejected["ok"]: + raise RuntimeError(f"Safety-reject path failed: {rejected['error']}") + if rejected["payload"].get("controller_decision") != "rejected": + raise RuntimeError("Controller unexpectedly accepted the unsafe request") + print(" cMCP policy: authorized") + print(" controller: rejected") + print(f" reason: {rejected['payload']['reason']}") + print(" execution: not_started") + print() + + close = client.post( + f"{gateway}/sessions/{session_id}/close", + timeout=10, + ) + close.raise_for_status() + claim = close.json() + + audit = client.get( + f"{gateway}/audit/export", + params={"session_id": session_id}, + timeout=10, + ) + audit.raise_for_status() + bundle = audit.json() + + _save_json(claim_out, claim) + _save_json(audit_out, bundle) + _verify_evidence(claim, bundle, require_hardware=require_hardware) + print(f" claim: {claim_out}") + print(f" audit: {audit_out}") + if print_claim: + print() + print(json.dumps(claim, indent=2)) + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Run governed robot motion scenarios through cMCP" + ) + parser.add_argument("--gateway", default=DEFAULT_GATEWAY) + parser.add_argument( + "--claim-out", + type=Path, + default=EXAMPLE_DIR / "trace-output" / "latest-trust-record.json", + ) + parser.add_argument( + "--audit-out", + type=Path, + default=EXAMPLE_DIR / "trace-output" / "latest-audit-bundle.json", + ) + parser.add_argument("--require-hardware", action="store_true") + parser.add_argument("--print-claim", action="store_true") + args = parser.parse_args() + run( + args.gateway, + args.claim_out, + args.audit_out, + require_hardware=args.require_hardware, + print_claim=args.print_claim, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/industrial-embodied-ai/artifact-hashes.json b/examples/industrial-embodied-ai/artifact-hashes.json new file mode 100644 index 00000000..83f0c7a0 --- /dev/null +++ b/examples/industrial-embodied-ai/artifact-hashes.json @@ -0,0 +1,6 @@ +{ + "cmcp_catalog_hash": "sha256:792c86ff8152fa9713d52584c084611eb4929fa5ebf3ec8271dd21f0e0aa7eeb", + "cmcp_policy_bundle_hash": "sha256:c8358148d201749ebd05651ea03cf92fb3ff8cc9cf05816483c394ebc3e1cac9", + "agent_manifest_tool_catalog_root": "sha256:3c7460301bbf38612b492de4a2095ccf40b7d921bdc9ce96e8ea0f50068d66e0", + "system_prompt_hash": "sha256:9b364864a1d6682f56f572052dd7a0701b35d9726046bcb06d5fa5d87b49a70f" +} diff --git a/examples/industrial-embodied-ai/artifacts/system-prompt.txt b/examples/industrial-embodied-ai/artifacts/system-prompt.txt new file mode 100644 index 00000000..734655b5 --- /dev/null +++ b/examples/industrial-embodied-ai/artifacts/system-prompt.txt @@ -0,0 +1,7 @@ +You are a material-movement planning agent for a synthetic industrial robot cell. + +You may read the cell safety state and request movement only through approved +tools. You must never claim that policy authorization proves a motion is safe. +The independent safety controller remains authoritative and may reject any +request. Report controller rejection or execution failure without retrying +unless a new approved workflow explicitly permits another attempt. diff --git a/examples/industrial-embodied-ai/catalog.json b/examples/industrial-embodied-ai/catalog.json new file mode 100644 index 00000000..9d9ac551 --- /dev/null +++ b/examples/industrial-embodied-ai/catalog.json @@ -0,0 +1,150 @@ +[ + { + "tool_name": "cell.read_safety_state", + "server": { + "display_name": "Independent Robot Cell Safety Controller (mock)", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Read an authenticated, short-lived safety-state snapshot from the independent robot cell controller.", + "input_schema": { + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { + "controller_id": { + "type": "string" + }, + "sequence": { + "type": "integer" + }, + "observed_at_ms": { + "type": "integer" + }, + "operating_mode": { + "type": "string" + }, + "emergency_stop_active": { + "type": "boolean" + }, + "protective_stop_active": { + "type": "boolean" + }, + "human_detected": { + "type": "boolean" + }, + "state_token": { + "type": "string" + } + }, + "required": [ + "controller_id", + "sequence", + "observed_at_ms", + "operating_mode", + "emergency_stop_active", + "protective_stop_active", + "human_detected", + "state_token" + ], + "additionalProperties": false + } + }, + "definition_hash": "sha256:c17915c854b0da7a6bc4b1fd7388914fb3783a7f333d558525a38568401af990", + "compliance_domain": "internal", + "requires_baa": false, + "sensitivity_level": "confidential", + "added_at": "2026-06-11T00:00:00Z", + "approved_by": "community-example@agentrust.io" + }, + { + "tool_name": "robot.request_motion", + "server": { + "display_name": "Independent Robot Cell Safety Controller (mock)", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Request a bounded material-movement operation. The independent controller may reject the request regardless of cMCP authorization.", + "input_schema": { + "type": "object", + "properties": { + "motion_id": { + "type": "string" + }, + "target": { + "type": "string", + "enum": [ + "buffer-zone-1", + "transfer-station-b" + ] + }, + "max_speed_mps": { + "type": "number", + "minimum": 0, + "maximum": 0.5 + }, + "safety_state_token": { + "type": "string" + } + }, + "required": [ + "motion_id", + "target", + "max_speed_mps", + "safety_state_token" + ], + "additionalProperties": false + }, + "output_schema": { + "type": "object", + "properties": { + "controller_id": { + "type": "string" + }, + "motion_id": { + "type": "string" + }, + "request_digest": { + "type": "string" + }, + "controller_decision": { + "type": "string", + "enum": [ + "accepted", + "rejected" + ] + }, + "execution_status": { + "type": "string", + "enum": [ + "not_started", + "completed", + "failed" + ] + } + }, + "required": [ + "controller_id", + "motion_id", + "request_digest", + "controller_decision", + "execution_status" + ], + "additionalProperties": false + } + }, + "definition_hash": "sha256:d65ecd2651a7ceaae0f6ef0e03ca951b2a52efea3052889899278fc6bfc067af", + "compliance_domain": "internal", + "requires_baa": false, + "sensitivity_level": "confidential", + "added_at": "2026-06-11T00:00:00Z", + "approved_by": "community-example@agentrust.io" + } +] diff --git a/examples/industrial-embodied-ai/cmcp-config.yaml b/examples/industrial-embodied-ai/cmcp-config.yaml new file mode 100644 index 00000000..bcdedd15 --- /dev/null +++ b/examples/industrial-embodied-ai/cmcp-config.yaml @@ -0,0 +1,9 @@ +attestation: + provider: auto + enforcement_mode: enforcing + validity_seconds: 86400 + staleness_policy: fail_closed +policy_bundle_path: ./policy +catalog_path: ./catalog.json +listen_addr: 127.0.0.1:8443 +audit_db_path: ./audit.db diff --git a/examples/industrial-embodied-ai/controller-receipt-public-key.json b/examples/industrial-embodied-ai/controller-receipt-public-key.json new file mode 100644 index 00000000..7891b7a6 --- /dev/null +++ b/examples/industrial-embodied-ai/controller-receipt-public-key.json @@ -0,0 +1,7 @@ +{ + "key_id": "c1b47b97b024ed8ef89572fb0ed92a15fdd48496d6138ec260e018bd345d3d2d", + "public_key_base64url": "eOBimyX_-wLLvWhQ3Jl2KnRULU8ZU-vK0z7eAn2gNoo", + "algorithm": "Ed25519", + "issuer": "spiffe://factory.example/controller/robot-cell-7", + "usage": "development-only external_execution_evidence verification key" +} diff --git a/examples/industrial-embodied-ai/controller.py b/examples/industrial-embodied-ai/controller.py new file mode 100644 index 00000000..94c43fc9 --- /dev/null +++ b/examples/industrial-embodied-ai/controller.py @@ -0,0 +1,214 @@ +"""Independent mock safety controller for the industrial example. + +This module models a controller boundary. It does not implement a safety +function, control physical hardware, or claim compliance with robot standards. +""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import json +import math +import secrets +import time +from typing import Any, Callable + +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + + +CONTROLLER_ID = "spiffe://factory.example/controller/robot-cell-7" +MAX_STATE_AGE_MS = 5_000 +MAX_SPEED_MPS = 0.5 +ALLOWED_TARGETS = frozenset({"buffer-zone-1", "transfer-station-b"}) + + +class SafetyRejected(RuntimeError): + """The independent controller rejected physical execution.""" + + +def canonical_bytes(value: Any) -> bytes: + """Return stable JSON bytes for hashing and signing.""" + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode() + + +def sha256_b64url(value: Any) -> str: + """Return a base64url-encoded SHA-256 digest.""" + return _b64url_encode(hashlib.sha256(canonical_bytes(value)).digest()) + + +def _b64url_encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode() + + +def _b64url_decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +class IndependentSafetyController: + """Mock controller that remains authoritative for simulated motion.""" + + def __init__( + self, + *, + clock: Callable[[], float] = time.time, + token_key: bytes | None = None, + ) -> None: + self._clock = clock + self._token_key = token_key or secrets.token_bytes(32) + self._sequence = 0 + self._consumed_sequences: set[int] = set() + # #301: deterministic development-only Ed25519 key the controller uses to + # sign execution receipts (external_execution_evidence). Fixed seed so the + # public key and any committed evidence are reproducible. Not for production. + self._receipt_key = Ed25519PrivateKey.from_private_bytes( + hashlib.sha256(b"development-only-mock-controller-receipt-key").digest() + ) + self._receipt_pub = self._receipt_key.public_key().public_bytes( + encoding=Encoding.Raw, format=PublicFormat.Raw + ) + self._state = { + "operating_mode": "automatic", + "emergency_stop_active": False, + "protective_stop_active": False, + "human_detected": False, + } + + def set_state(self, **changes: Any) -> None: + unknown = set(changes) - set(self._state) + if unknown: + raise ValueError(f"Unknown controller state fields: {sorted(unknown)}") + self._state.update(changes) + + def read_safety_state(self) -> dict[str, Any]: + self._sequence += 1 + snapshot = { + "controller_id": CONTROLLER_ID, + "sequence": self._sequence, + "observed_at_ms": int(self._clock() * 1000), + **self._state, + } + payload = canonical_bytes(snapshot) + signature = hmac.new(self._token_key, payload, hashlib.sha256).digest() + return { + **snapshot, + "state_token": f"{_b64url_encode(payload)}.{_b64url_encode(signature)}", + } + + def _verify_state_token(self, token: str) -> dict[str, Any]: + try: + payload_b64, signature_b64 = token.split(".", maxsplit=1) + payload = _b64url_decode(payload_b64) + signature = _b64url_decode(signature_b64) + expected = hmac.new(self._token_key, payload, hashlib.sha256).digest() + if not hmac.compare_digest(signature, expected): + raise SafetyRejected("invalid_safety_state_token") + snapshot = json.loads(payload) + except (ValueError, json.JSONDecodeError) as exc: + raise SafetyRejected("malformed_safety_state_token") from exc + + try: + age_ms = int(self._clock() * 1000) - int(snapshot["observed_at_ms"]) + sequence = int(snapshot["sequence"]) + except (KeyError, TypeError, ValueError) as exc: + raise SafetyRejected("malformed_safety_state_token") from exc + + if age_ms < 0 or age_ms > MAX_STATE_AGE_MS: + raise SafetyRejected("stale_safety_state") + if snapshot.get("controller_id") != CONTROLLER_ID: + raise SafetyRejected("unexpected_controller_identity") + if sequence in self._consumed_sequences: + raise SafetyRejected("replayed_safety_state") + + self._consumed_sequences.add(sequence) + return snapshot + + def request_motion(self, request: dict[str, Any]) -> dict[str, Any]: + self._verify_state_token(str(request.get("safety_state_token", ""))) + + # The request token is evidence of a recent observation, not permission + # to move. The controller rechecks its authoritative current state. + if self._state["emergency_stop_active"]: + raise SafetyRejected("emergency_stop_active") + if self._state["protective_stop_active"]: + raise SafetyRejected("protective_stop_active") + if self._state["human_detected"]: + raise SafetyRejected("human_detected") + if self._state["operating_mode"] != "automatic": + raise SafetyRejected("controller_not_in_automatic_mode") + + motion_id = request.get("motion_id") + target = request.get("target") + try: + speed = float(request["max_speed_mps"]) + except (KeyError, TypeError, ValueError, OverflowError) as exc: + raise SafetyRejected("invalid_motion_request") from exc + + if not isinstance(motion_id, str) or not motion_id: + raise SafetyRejected("invalid_motion_request") + if not isinstance(target, str) or target not in ALLOWED_TARGETS: + raise SafetyRejected("target_outside_approved_zone") + # NaN fails both comparisons, so it must be refused explicitly. + if not math.isfinite(speed) or speed < 0 or speed > MAX_SPEED_MPS: + raise SafetyRejected("speed_exceeds_controller_limit") + + request_for_hash = { + key: value + for key, value in request.items() + if key != "safety_state_token" + } + return { + "controller_id": CONTROLLER_ID, + "motion_id": motion_id, + "request_digest": sha256_b64url(request_for_hash), + "controller_decision": "accepted", + "execution_status": "completed", + } + + @property + def receipt_key_id(self) -> str: + """Stable id for the receipt-signing public key (sha256 hex of the key).""" + return hashlib.sha256(self._receipt_pub).hexdigest() + + @property + def receipt_public_key_b64(self) -> str: + """base64url of the raw Ed25519 receipt-signing public key.""" + return _b64url_encode(self._receipt_pub) + + @property + def receipt_public_key_bytes(self) -> bytes: + """Raw Ed25519 receipt-signing public key bytes for cMCP verification.""" + return self._receipt_pub + + def sign_execution_receipt( + self, *, call_id: str, decision: dict[str, Any] + ) -> dict[str, str]: + """Return a controller-signed execution receipt for a decision. + + Matches the cMCP external_execution_evidence format (issue #301): the + signature is Ed25519 over the canonical receipt with the signature field + absent, and a verifier checks linked_call_id against the audit entry + call_id. This attests what the independent controller decided. It is not + a claim that a physical action was safe, accepted, or certified. + """ + receipt = { + "issuer": CONTROLLER_ID, + "issuer_key_id": self.receipt_key_id, + "evidence_hash": ( + "sha256:" + + hashlib.sha256(canonical_bytes(decision)).hexdigest() + ), + "evidence_type": "controller-execution-receipt/v1", + "linked_call_id": call_id, + } + receipt["signature"] = _b64url_encode( + self._receipt_key.sign(canonical_bytes(receipt)) + ) + return receipt diff --git a/examples/industrial-embodied-ai/manifest-public-key.json b/examples/industrial-embodied-ai/manifest-public-key.json new file mode 100644 index 00000000..626c67ea --- /dev/null +++ b/examples/industrial-embodied-ai/manifest-public-key.json @@ -0,0 +1,5 @@ +{ + "algorithm": "Ed25519", + "key_id": "cd3708166771d7bd74a49e8d307842c318aded09c0348688d7ac6e40952a5522", + "public_key_base64url": "LaAQ_icZoBzLRqqAH6yR6kbiU0ZfTc9hEpClDoVBOPs" +} diff --git a/examples/industrial-embodied-ai/policy/allow.cedar b/examples/industrial-embodied-ai/policy/allow.cedar new file mode 100644 index 00000000..b8089d81 --- /dev/null +++ b/examples/industrial-embodied-ai/policy/allow.cedar @@ -0,0 +1,22 @@ +// Industrial material-movement policy bundle. +// cMCP maps cell.read_safety_state to Action::"Cell.readSafetyState" and +// robot.request_motion to Action::"Robot.requestMotion". +// No catch-all permit: only the declared workflow and tools are authorized. + +permit ( + principal, + action == Action::"Cell.readSafetyState", + resource +) when { + context has workflow_id && + context.workflow_id == "industrial-material-movement" +}; + +permit ( + principal, + action == Action::"Robot.requestMotion", + resource +) when { + context has workflow_id && + context.workflow_id == "industrial-material-movement" +}; diff --git a/examples/industrial-embodied-ai/policy/manifest.json b/examples/industrial-embodied-ai/policy/manifest.json new file mode 100644 index 00000000..f1a251df --- /dev/null +++ b/examples/industrial-embodied-ai/policy/manifest.json @@ -0,0 +1,7 @@ +{ + "version": "0.1.0", + "authored_at": "2026-06-11T00:00:00Z", + "author_identity": "community-example@agentrust.io", + "commit_sha": "development-example", + "approval_chain": [] +} diff --git a/examples/industrial-embodied-ai/policy/schema.cedarschema b/examples/industrial-embodied-ai/policy/schema.cedarschema new file mode 100644 index 00000000..875ae43c --- /dev/null +++ b/examples/industrial-embodied-ai/policy/schema.cedarschema @@ -0,0 +1 @@ +{"cMCP":{}} diff --git a/examples/industrial-embodied-ai/requirements.txt b/examples/industrial-embodied-ai/requirements.txt new file mode 100644 index 00000000..126adf49 --- /dev/null +++ b/examples/industrial-embodied-ai/requirements.txt @@ -0,0 +1,4 @@ +agent-manifest>=0.12.0 +cmcp-runtime>=0.5.0 +cedarpy==4.12.1 +httpx>=0.28.1,<1 diff --git a/examples/industrial-embodied-ai/server/mock_robot_controller.py b/examples/industrial-embodied-ai/server/mock_robot_controller.py new file mode 100644 index 00000000..674ece6b --- /dev/null +++ b/examples/industrial-embodied-ai/server/mock_robot_controller.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Mock robot-cell MCP server for the industrial embodied-AI example.""" + +from __future__ import annotations + +import json +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from typing import Any + + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EXAMPLE_DIR)) + +from controller import IndependentSafetyController, SafetyRejected # noqa: E402 + + +PORT = 8080 +controller = IndependentSafetyController( + token_key=b"development-only-mock-controller-key" +) +completed_motions = 0 + + +def _read_safety_state(arguments: dict[str, Any]) -> dict[str, Any]: + del arguments + snapshot = controller.read_safety_state() + + # After the first successful motion, the next read represents a person + # entering the cell after observation but before the motion request. + if completed_motions > 0: + controller.set_state(human_detected=True) + print("[mock-controller] environment changed: human_detected=true") + return snapshot + + +def _request_motion(arguments: dict[str, Any]) -> dict[str, Any]: + global completed_motions + try: + result = controller.request_motion(arguments) + completed_motions += 1 + return result + except SafetyRejected as exc: + reason = str(exc) + result = { + "controller_id": "spiffe://factory.example/controller/robot-cell-7", + "motion_id": arguments.get("motion_id"), + "controller_decision": "rejected", + "execution_status": "not_started", + "reason": reason, + } + if reason == "human_detected": + # Reset only the synthetic stage condition so the demo is repeatable. + controller.set_state(human_detected=False) + completed_motions = 0 + return result + + +def _with_execution_receipt(result: dict[str, Any], call_id: object) -> dict[str, Any]: + receipt = controller.sign_execution_receipt( + call_id=str(call_id), + decision=result, + ) + return {**result, "external_execution_evidence": receipt} + + +TOOLS = { + "cell.read_safety_state": _read_safety_state, + "robot.request_motion": _request_motion, +} + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self) -> None: # noqa: N802 + if self.path != "/mcp": + self._reply(404, {"error": "not found"}) + return + + try: + length = int(self.headers.get("Content-Length", 0)) + request = json.loads(self.rfile.read(length)) + except (ValueError, json.JSONDecodeError): + self._reply(400, {"error": "invalid JSON"}) + return + + params = request.get("params", {}) + tool_name = params.get("name", "") + handler = TOOLS.get(tool_name) + if handler is None: + self._reply( + 200, + { + "jsonrpc": "2.0", + "id": request.get("id"), + "error": { + "code": -32601, + "message": f"unknown tool: {tool_name}", + }, + }, + ) + return + + result = handler(params.get("arguments", {})) + if tool_name == "robot.request_motion": + result = _with_execution_receipt(result, request.get("id")) + self._reply( + 200, + { + "jsonrpc": "2.0", + "id": request.get("id"), + "result": { + "content": [ + { + "type": "text", + "text": json.dumps(result, sort_keys=True), + } + ] + }, + }, + ) + + def _reply(self, status: int, body: dict[str, Any]) -> None: + payload = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, fmt: str, *args: Any) -> None: + print(f"[mock-controller] {fmt % args}") + + +if __name__ == "__main__": + print( + "Mock Robot Cell MCP Server listening on :8080 " + f"(tools: {', '.join(TOOLS)})" + ) + HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/examples/industrial-embodied-ai/tests/test_controller.py b/examples/industrial-embodied-ai/tests/test_controller.py new file mode 100644 index 00000000..10f1d8df --- /dev/null +++ b/examples/industrial-embodied-ai/tests/test_controller.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EXAMPLE_DIR)) + +from controller import IndependentSafetyController, SafetyRejected # noqa: E402 + + +class MutableClock: + def __init__(self, value: float = 1_781_179_200.0) -> None: + self.value = value + + def __call__(self) -> float: + return self.value + + +class IndependentSafetyControllerTests(unittest.TestCase): + def setUp(self) -> None: + self.clock = MutableClock() + self.controller = IndependentSafetyController( + clock=self.clock, + token_key=b"unit-test-only-controller-key", + ) + + def request(self, token: str, **overrides: object) -> dict[str, object]: + request: dict[str, object] = { + "motion_id": "move-0001", + "target": "buffer-zone-1", + "max_speed_mps": 0.2, + "safety_state_token": token, + } + request.update(overrides) + return request + + def test_safe_motion_is_accepted(self) -> None: + snapshot = self.controller.read_safety_state() + result = self.controller.request_motion( + self.request(snapshot["state_token"]) + ) + self.assertEqual(result["controller_decision"], "accepted") + self.assertEqual(result["execution_status"], "completed") + + def test_controller_rechecks_current_state(self) -> None: + snapshot = self.controller.read_safety_state() + self.controller.set_state(human_detected=True) + with self.assertRaisesRegex(SafetyRejected, "human_detected"): + self.controller.request_motion( + self.request(snapshot["state_token"]) + ) + + def test_stale_state_token_is_rejected(self) -> None: + snapshot = self.controller.read_safety_state() + self.clock.value += 5.001 + with self.assertRaisesRegex(SafetyRejected, "stale_safety_state"): + self.controller.request_motion( + self.request(snapshot["state_token"]) + ) + + def test_modified_state_token_is_rejected(self) -> None: + snapshot = self.controller.read_safety_state() + token = snapshot["state_token"] + replacement = "A" if token[-1] != "A" else "B" + with self.assertRaisesRegex( + SafetyRejected, + "invalid_safety_state_token", + ): + self.controller.request_motion( + self.request(token[:-1] + replacement) + ) + + def test_state_token_cannot_be_replayed(self) -> None: + snapshot = self.controller.read_safety_state() + request = self.request(snapshot["state_token"]) + self.controller.request_motion(request) + with self.assertRaisesRegex(SafetyRejected, "replayed_safety_state"): + self.controller.request_motion( + {**request, "motion_id": "move-0002"} + ) + + def test_speed_limit_is_controller_authoritative(self) -> None: + snapshot = self.controller.read_safety_state() + with self.assertRaisesRegex( + SafetyRejected, + "speed_exceeds_controller_limit", + ): + self.controller.request_motion( + self.request(snapshot["state_token"], max_speed_mps=0.8) + ) + + def test_non_finite_speed_is_rejected(self) -> None: + # NaN compares false against both bounds, so a range check alone lets + # it through. json.loads accepts a bare NaN, so it can arrive by wire. + for speed in (float("nan"), "nan", "NaN", float("inf"), "-inf"): + with self.subTest(speed=speed): + snapshot = self.controller.read_safety_state() + with self.assertRaisesRegex( + SafetyRejected, + "speed_exceeds_controller_limit", + ): + self.controller.request_motion( + self.request(snapshot["state_token"], max_speed_mps=speed) + ) + + def test_speed_too_large_for_float_is_rejected(self) -> None: + # float() of an integer past the double range raises OverflowError, + # which is not a SafetyRejected and would escape the controller. + snapshot = self.controller.read_safety_state() + with self.assertRaisesRegex(SafetyRejected, "invalid_motion_request"): + self.controller.request_motion( + self.request(snapshot["state_token"], max_speed_mps=10**400) + ) + + def test_target_must_be_in_approved_zone(self) -> None: + snapshot = self.controller.read_safety_state() + with self.assertRaisesRegex( + SafetyRejected, + "target_outside_approved_zone", + ): + self.controller.request_motion( + self.request(snapshot["state_token"], target="loading-dock") + ) + + def test_safe_motion_is_not_proof_of_authorization(self) -> None: + """An individually safe motion is not, by itself, a trusted one. + + The controller accepts a motion purely on its physical safety + envelope: a fresh state token, an approved zone, an in-limit speed and + no human present. It has no field for workflow, declared purpose or + agent identity, so it cannot tell an in-scope motion from an + out-of-scope one. Catching the out-of-scope case is the trust layer's + job (the workflow-scoped Cedar policy in policy/allow.cedar), not the + safety controller's. This test pins the controller side of that + boundary: a safe motion is accepted, and the accept decision carries + no authorization claim. + """ + snapshot = self.controller.read_safety_state() + accepted = self.controller.request_motion( + self.request(snapshot["state_token"]) + ) + self.assertEqual(accepted["controller_decision"], "accepted") + self.assertEqual(accepted["execution_status"], "completed") + # The accept result says nothing about scope, declared purpose or which + # agent issued the request. None of these are inputs the controller can + # see, which is exactly why physical safety cannot stand in for trust. + for authorization_field in ("workflow_id", "agent_id", "declared_scope"): + self.assertNotIn(authorization_field, accepted) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/industrial-embodied-ai/tests/test_execution_receipt.py b/examples/industrial-embodied-ai/tests/test_execution_receipt.py new file mode 100644 index 00000000..332ffa61 --- /dev/null +++ b/examples/industrial-embodied-ai/tests/test_execution_receipt.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import sys +import unittest +from pathlib import Path + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EXAMPLE_DIR)) + +from controller import IndependentSafetyController # noqa: E402 +from server import mock_robot_controller # noqa: E402 + + +def _b64url_decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def _verify_like_cmcp( + receipt: dict, + external_evidence_keys: dict[str, bytes], + call_id: str, +) -> None: + """Reproduce cmcp_verify's external_execution_evidence check: the + trusted key-id map, linked_call_id binding, and Ed25519 signature over the + canonical receipt with the signature field absent. Raises on verification + failure. + """ + if receipt["linked_call_id"] != call_id: + raise AssertionError("linked_call_id does not match the entry call_id") + key_id = receipt["issuer_key_id"] + public_key = external_evidence_keys[key_id] + if hashlib.sha256(public_key).hexdigest() != key_id: + raise AssertionError("issuer_key_id does not match trusted public key") + pub = Ed25519PublicKey.from_public_bytes(public_key) + signing_input = json.dumps( + {k: v for k, v in receipt.items() if k != "signature"}, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode() + pub.verify(_b64url_decode(receipt["signature"]), signing_input) + + +class ExecutionReceiptTests(unittest.TestCase): + def setUp(self) -> None: + self.controller = IndependentSafetyController( + token_key=b"unit-test-only-controller-key" + ) + + def test_receipt_has_required_fields(self) -> None: + receipt = self.controller.sign_execution_receipt( + call_id="c1", + decision={"controller_decision": "rejected", "reason": "human_detected"}, + ) + for field in ( + "issuer", + "issuer_key_id", + "signature", + "evidence_hash", + "evidence_type", + "linked_call_id", + ): + self.assertIn(field, receipt) + self.assertTrue(receipt["evidence_hash"].startswith("sha256:")) + self.assertEqual(receipt["evidence_type"], "controller-execution-receipt/v1") + self.assertEqual( + receipt["issuer_key_id"], + hashlib.sha256(self.controller.receipt_public_key_bytes).hexdigest(), + ) + + def test_receipt_verifies_like_cmcp(self) -> None: + receipt = self.controller.sign_execution_receipt( + call_id="c1", + decision={"controller_decision": "rejected", "reason": "human_detected"}, + ) + # Should not raise. + _verify_like_cmcp( + receipt, + {self.controller.receipt_key_id: self.controller.receipt_public_key_bytes}, + "c1", + ) + + def test_tampered_receipt_fails(self) -> None: + receipt = self.controller.sign_execution_receipt( + call_id="c1", decision={"controller_decision": "rejected"} + ) + receipt["evidence_hash"] = "sha256:" + "cd" * 32 # tamper after signing + with self.assertRaises(InvalidSignature): + _verify_like_cmcp( + receipt, + { + self.controller.receipt_key_id: ( + self.controller.receipt_public_key_bytes + ) + }, + "c1", + ) + + def test_linked_call_id_is_bound(self) -> None: + receipt = self.controller.sign_execution_receipt( + call_id="c1", decision={"controller_decision": "rejected"} + ) + with self.assertRaises(AssertionError): + _verify_like_cmcp( + receipt, + { + self.controller.receipt_key_id: ( + self.controller.receipt_public_key_bytes + ) + }, + "other", + ) + + def test_receipt_key_is_deterministic(self) -> None: + # Fixed dev seed: the receipt key id is reproducible across instances, + # so committed evidence stays stable. + other = IndependentSafetyController(token_key=b"different-token-key") + self.assertEqual(self.controller.receipt_key_id, other.receipt_key_id) + self.assertEqual( + self.controller.receipt_public_key_b64, + "eOBimyX_-wLLvWhQ3Jl2KnRULU8ZU-vK0z7eAn2gNoo", + ) + + def test_mock_server_attaches_receipt_to_motion_decision(self) -> None: + result = mock_robot_controller._with_execution_receipt( + {"controller_decision": "rejected", "reason": "human_detected"}, + "call-42", + ) + receipt = result["external_execution_evidence"] + self.assertEqual(receipt["linked_call_id"], "call-42") + _verify_like_cmcp( + receipt, + { + mock_robot_controller.controller.receipt_key_id: ( + mock_robot_controller.controller.receipt_public_key_bytes + ) + }, + "call-42", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/industrial-embodied-ai/tests/test_validate_artifacts.py b/examples/industrial-embodied-ai/tests/test_validate_artifacts.py new file mode 100644 index 00000000..046a90c7 --- /dev/null +++ b/examples/industrial-embodied-ai/tests/test_validate_artifacts.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +import ast +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parents[1] / "validate_artifacts.py" + + +class ValidateArtifactsTests(unittest.TestCase): + def test_checks_survive_python_optimize(self) -> None: + # validate_artifacts.py needs the published stack, which this job does + # not install, so this pins the property statically. Under python -O + # every assert is stripped, and a tampered artifact then printed + # "valid" for all four checks. + tree = ast.parse(SCRIPT.read_text(encoding="utf-8")) + asserts = [node.lineno for node in ast.walk(tree) if isinstance(node, ast.Assert)] + self.assertEqual(asserts, [], f"assert used for validation at lines {asserts}") + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/industrial-embodied-ai/trace-output/example-audit-bundle.json b/examples/industrial-embodied-ai/trace-output/example-audit-bundle.json new file mode 100644 index 00000000..c865dd3d --- /dev/null +++ b/examples/industrial-embodied-ai/trace-output/example-audit-bundle.json @@ -0,0 +1,238 @@ +{ + "session_id": "858ca4a7-82e7-4a9a-bbdc-e036eef06b78", + "entries": [ + { + "entry_id": "c7c2ef05-90c1-46eb-8d65-12d7af5c8167", + "sequence_number": 0, + "timestamp_utc": "2026-08-10T18:41:10.620186+00:00", + "session_id": "858ca4a7-82e7-4a9a-bbdc-e036eef06b78", + "call_id": null, + "entry_type": "session_start", + "tool_name": null, + "server_identity": null, + "policy_decision": "n/a", + "policy_rule_matched": null, + "latency_us": null, + "request_payload_hash": null, + "response_payload_hash": null, + "response_inspection_result": "n/a", + "session_sensitivity_before": null, + "session_sensitivity_after": "public", + "detail": null, + "workflow_id": null, + "prev_entry_hash": "genesis", + "evidence_class": "hash-only", + "external_execution_evidence": null, + "entry_hash": "d164dabad3533f211b26612562f0cc89dc3071f66e07e6af928645c2ecb60cc2" + }, + { + "entry_id": "d4e06511-d784-4077-9708-1ee9173dd8b4", + "sequence_number": 1, + "timestamp_utc": "2026-08-10T18:41:47.969186+00:00", + "session_id": "858ca4a7-82e7-4a9a-bbdc-e036eef06b78", + "call_id": "420bac24-4407-4978-88b6-7c91fa17cfe1", + "entry_type": "fault", + "tool_name": "cell.read_safety_state", + "server_identity": "http://localhost:8080/mcp", + "policy_decision": "fault", + "policy_rule_matched": "upstream:UPSTREAM_TOOL_ERROR", + "latency_us": null, + "request_payload_hash": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "response_payload_hash": null, + "response_inspection_result": null, + "session_sensitivity_before": "public", + "session_sensitivity_after": "public", + "detail": { + "error_code": "UPSTREAM_TOOL_ERROR" + }, + "workflow_id": null, + "prev_entry_hash": "d164dabad3533f211b26612562f0cc89dc3071f66e07e6af928645c2ecb60cc2", + "evidence_class": "hash-only", + "external_execution_evidence": null, + "entry_hash": "50fe056044cccb89486d3b3cd876d3644698a9ec3632a3d4fc5193e2f5fb20a3" + }, + { + "entry_id": "456189c1-7922-4509-a372-73d05fb4debf", + "sequence_number": 2, + "timestamp_utc": "2026-08-10T18:50:57.230878+00:00", + "session_id": "858ca4a7-82e7-4a9a-bbdc-e036eef06b78", + "call_id": "b67c3c3f-c3c4-4dfa-ad36-134449dafdfc", + "entry_type": "tool_call", + "tool_name": "cell.read_safety_state", + "server_identity": "http://localhost:8080/mcp", + "policy_decision": "allow", + "policy_rule_matched": "Cedar (cedarpy): allowed", + "latency_us": 282798, + "request_payload_hash": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "response_payload_hash": "sha256:aa20d0f0f69c19689d5fd6a98e03adae6090637f35494cc77eae89c0a6c7e14d", + "response_inspection_result": null, + "session_sensitivity_before": "public", + "session_sensitivity_after": "confidential", + "detail": null, + "workflow_id": "industrial-material-movement", + "prev_entry_hash": "50fe056044cccb89486d3b3cd876d3644698a9ec3632a3d4fc5193e2f5fb20a3", + "evidence_class": "hash-only", + "external_execution_evidence": null, + "entry_hash": "d40585a3500c715515c7f372a3bb2a911a485d90f89804df5bb0bb9e4f9ff580" + }, + { + "entry_id": "33790dea-94e6-4231-a798-2f03d7af4087", + "sequence_number": 3, + "timestamp_utc": "2026-08-10T18:50:57.238846+00:00", + "session_id": "858ca4a7-82e7-4a9a-bbdc-e036eef06b78", + "call_id": "a8f1f749-ef6d-4ccb-bb21-4f34d27f533a", + "entry_type": "tool_call", + "tool_name": "robot.request_motion", + "server_identity": "http://localhost:8080/mcp", + "policy_decision": "deny", + "policy_rule_matched": "Policy denied tool call: robot.request_motion", + "latency_us": null, + "request_payload_hash": "sha256:9c5d12d5858bbf3223347d1aafb8e16a9af70964e460d4dc76d52d565b7fac49", + "response_payload_hash": null, + "response_inspection_result": null, + "session_sensitivity_before": "confidential", + "session_sensitivity_after": "confidential", + "detail": null, + "workflow_id": "unapproved-diagnostics", + "prev_entry_hash": "d40585a3500c715515c7f372a3bb2a911a485d90f89804df5bb0bb9e4f9ff580", + "evidence_class": "hash-only", + "external_execution_evidence": null, + "entry_hash": "1ce7e92f959c6179be66f65b94cfae638547f5112efcd272356a7d70caa1a4f1" + }, + { + "entry_id": "363aac14-c5f8-4f94-a5a3-c65404036c84", + "sequence_number": 4, + "timestamp_utc": "2026-08-10T18:50:57.526860+00:00", + "session_id": "858ca4a7-82e7-4a9a-bbdc-e036eef06b78", + "call_id": "2cf574b6-6dd1-4154-bd64-6098eb3fa547", + "entry_type": "tool_call", + "tool_name": "cell.read_safety_state", + "server_identity": "http://localhost:8080/mcp", + "policy_decision": "allow", + "policy_rule_matched": "Cedar (cedarpy): allowed", + "latency_us": 284532, + "request_payload_hash": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "response_payload_hash": "sha256:0d3080ff87976eb5c954b7d78c12db2c2a389d6bb368cdd92251e192b2697fce", + "response_inspection_result": null, + "session_sensitivity_before": "confidential", + "session_sensitivity_after": "confidential", + "detail": null, + "workflow_id": "industrial-material-movement", + "prev_entry_hash": "1ce7e92f959c6179be66f65b94cfae638547f5112efcd272356a7d70caa1a4f1", + "evidence_class": "hash-only", + "external_execution_evidence": null, + "entry_hash": "003f5f84001b6a29b0b71fb72c346ad429c39fa9df477039f34b15bfe7b97854" + }, + { + "entry_id": "f8a06936-8be7-41f6-856c-bfda924f7d67", + "sequence_number": 5, + "timestamp_utc": "2026-08-10T18:50:57.838543+00:00", + "session_id": "858ca4a7-82e7-4a9a-bbdc-e036eef06b78", + "call_id": "357131c2-0c12-4a62-98cb-95362e79e192", + "entry_type": "tool_call", + "tool_name": "robot.request_motion", + "server_identity": "http://localhost:8080/mcp", + "policy_decision": "allow", + "policy_rule_matched": "Cedar (cedarpy): allowed", + "latency_us": 303364, + "request_payload_hash": "sha256:b493f471549301e14fa85214e06a0168131565b0deed57baebce02f93658d4fd", + "response_payload_hash": "sha256:132063618d3a4f0b693545796fa4b4a0f2dd45c36631b881c469d5526da520a3", + "response_inspection_result": null, + "session_sensitivity_before": "confidential", + "session_sensitivity_after": "confidential", + "detail": null, + "workflow_id": "industrial-material-movement", + "prev_entry_hash": "003f5f84001b6a29b0b71fb72c346ad429c39fa9df477039f34b15bfe7b97854", + "evidence_class": "hash-only", + "external_execution_evidence": { + "evidence_hash": "sha256:09d167659cb39772db3baae129dca4cd840f83173c8da5f324486d454c3c2f85", + "evidence_type": "controller-execution-receipt/v1", + "issuer": "spiffe://factory.example/controller/robot-cell-7", + "issuer_key_id": "c1b47b97b024ed8ef89572fb0ed92a15fdd48496d6138ec260e018bd345d3d2d", + "linked_call_id": "357131c2-0c12-4a62-98cb-95362e79e192", + "signature": "PyT4A6WRQJXgyt4WCIxJ8ISdPRoIXUac5MrGsXdq8-1oMZKO2mmRLp-25Rc866YGJKIhsS6lABeCBgzsTwOhAg" + }, + "entry_hash": "6b3aab9fcb880759e5f849430b271030b86ef0d1c0aca089edccd2a15a197483" + }, + { + "entry_id": "6d22892f-5c8a-4a57-aaa5-e8865507f6e3", + "sequence_number": 6, + "timestamp_utc": "2026-08-10T18:50:58.117771+00:00", + "session_id": "858ca4a7-82e7-4a9a-bbdc-e036eef06b78", + "call_id": "97262c49-b814-40d0-a7f2-09c603f37c42", + "entry_type": "tool_call", + "tool_name": "cell.read_safety_state", + "server_identity": "http://localhost:8080/mcp", + "policy_decision": "allow", + "policy_rule_matched": "Cedar (cedarpy): allowed", + "latency_us": 271522, + "request_payload_hash": "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a", + "response_payload_hash": "sha256:375ef673c65968aa27df02e496b42e08beadb6c16548372c530159d716ac0d4b", + "response_inspection_result": null, + "session_sensitivity_before": "confidential", + "session_sensitivity_after": "confidential", + "detail": null, + "workflow_id": "industrial-material-movement", + "prev_entry_hash": "6b3aab9fcb880759e5f849430b271030b86ef0d1c0aca089edccd2a15a197483", + "evidence_class": "hash-only", + "external_execution_evidence": null, + "entry_hash": "6b93a830c02263d53f9a15a4e6f397c7d1604994a2df0a2c611dda796e517ddb" + }, + { + "entry_id": "09f4edbe-b984-4e70-aa17-933f2e1d9a75", + "sequence_number": 7, + "timestamp_utc": "2026-08-10T18:50:58.402346+00:00", + "session_id": "858ca4a7-82e7-4a9a-bbdc-e036eef06b78", + "call_id": "6be61265-c1bb-451d-982d-888487408b2c", + "entry_type": "tool_call", + "tool_name": "robot.request_motion", + "server_identity": "http://localhost:8080/mcp", + "policy_decision": "allow", + "policy_rule_matched": "Cedar (cedarpy): allowed", + "latency_us": 275797, + "request_payload_hash": "sha256:9e1c9799e68c8a69d5a06a40055227fb5afee6c4ba9625c0aa49e998f8a3cb92", + "response_payload_hash": "sha256:5480ca2d9bdc1319b5fca86d787452fe287c89f1db242c1aa7dca962fd120b90", + "response_inspection_result": null, + "session_sensitivity_before": "confidential", + "session_sensitivity_after": "confidential", + "detail": null, + "workflow_id": "industrial-material-movement", + "prev_entry_hash": "6b93a830c02263d53f9a15a4e6f397c7d1604994a2df0a2c611dda796e517ddb", + "evidence_class": "hash-only", + "external_execution_evidence": { + "evidence_hash": "sha256:a92a9571b42fda3f5a3715d9fa4b984f2f679cccd3dc146a84f162dad9cd7ba8", + "evidence_type": "controller-execution-receipt/v1", + "issuer": "spiffe://factory.example/controller/robot-cell-7", + "issuer_key_id": "c1b47b97b024ed8ef89572fb0ed92a15fdd48496d6138ec260e018bd345d3d2d", + "linked_call_id": "6be61265-c1bb-451d-982d-888487408b2c", + "signature": "zchHdFrDPXNKySa86gVx7cFanEiQ9ybUbjAYUQwkt6guu60xLz5UgkUMkzQAwqYqrNyw1rhVkDsc_JJzg-1PCQ" + }, + "entry_hash": "616aa7d6237e193489ce88746f8ad7ea95e0e5a79ec413b521c2449545c8cf5b" + }, + { + "entry_id": "fd758fdb-da89-4a6f-a61e-303f882be4bf", + "sequence_number": 8, + "timestamp_utc": "2026-08-10T18:50:58.411514+00:00", + "session_id": "858ca4a7-82e7-4a9a-bbdc-e036eef06b78", + "call_id": null, + "entry_type": "session_end", + "tool_name": null, + "server_identity": null, + "policy_decision": null, + "policy_rule_matched": null, + "latency_us": null, + "request_payload_hash": null, + "response_payload_hash": null, + "response_inspection_result": null, + "session_sensitivity_before": "confidential", + "session_sensitivity_after": "confidential", + "detail": null, + "workflow_id": null, + "prev_entry_hash": "616aa7d6237e193489ce88746f8ad7ea95e0e5a79ec413b521c2449545c8cf5b", + "evidence_class": "hash-only", + "external_execution_evidence": null, + "entry_hash": "a579a4ac28dad83d24c5f021d648f5be68c02ae1dcb163dc9416ef2710fc9849" + } + ], + "bundle_signature": "MHCupwSpzs38raknoRR1k0nmbxQz13qC06m7ihoT_s6mRu9wI1Zg5AdUOuzaQjikLem4lPSJyCVtO7I6q0ttCg" +} diff --git a/examples/industrial-embodied-ai/trace-output/example-trust-record.json b/examples/industrial-embodied-ai/trace-output/example-trust-record.json new file mode 100644 index 00000000..56de7cbb --- /dev/null +++ b/examples/industrial-embodied-ai/trace-output/example-trust-record.json @@ -0,0 +1,109 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust-io.com,2026:trace-v0.2", + "iat": 1786387858, + "subject": "spiffe://cmcp.gateway/tee/249d2d132ccc898f", + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:c8358148d201749ebd05651ea03cf92fb3ff8cc9cf05816483c394ebc3e1cac9", + "enforcement_mode": "enforce", + "version": "0.1.0" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:a579a4ac28dad83d24c5f021d648f5be68c02ae1dcb163dc9416ef2710fc9849", + "call_count": 6, + "entries": [ + { + "tool_name": "cell.read_safety_state", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "robot.request_motion", + "data_class": "confidential", + "decision": "deny" + }, + { + "tool_name": "cell.read_safety_state", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "robot.request_motion", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "cell.read_safety_state", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "robot.request_motion", + "data_class": "confidential", + "decision": "allow" + } + ] + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "JJ0tEyzMiY_on6Iq6bjOAMbWKxjtjRYzVFajIbU89Ts", + "kid": "cmcp-249d2d13" + } + } + }, + "gateway": { + "session_id": "858ca4a7-82e7-4a9a-bbdc-e036eef06b78", + "gateway_version": "0.4.0", + "sequence_number": 1, + "audit_chain": { + "root": "d164dabad3533f211b26612562f0cc89dc3071f66e07e6af928645c2ecb60cc2", + "tip": "a579a4ac28dad83d24c5f021d648f5be68c02ae1dcb163dc9416ef2710fc9849", + "length": 9 + }, + "call_summary": { + "tool_calls_total": 6, + "tool_calls_allowed": 5, + "tool_calls_denied": 1, + "tool_calls_faulted": 0, + "tools_invoked": [ + "cell.read_safety_state", + "robot.request_motion" + ], + "session_max_sensitivity": "confidential", + "call_graph_summary": { + "compliance_domains_touched": [ + "internal" + ], + "cross_boundary_events": [], + "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." + } + }, + "catalog": { + "hash": "sha256:792c86ff8152fa9713d52584c084611eb4929fa5ebf3ec8271dd21f0e0aa7eeb", + "drift_detected": false + }, + "attestation_generated_at": "2026-08-10T18:41:10.622668+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 7, + "tools_called": [ + "cell.read_safety_state", + "robot.request_motion" + ], + "suspicious_sequences_detected": 0 + }, + "kill_switch_triggered": false + }, + "signature": "KcNee4vwaRQeNvEvo8oQVyfPGStUvVUN3tpWGapnypUpckmvvrRcqM5qZILqExehDjNEqbLbDmDWlLxPuy9tAw" +} diff --git a/examples/industrial-embodied-ai/validate_artifacts.py b/examples/industrial-embodied-ai/validate_artifacts.py new file mode 100644 index 00000000..16cc6800 --- /dev/null +++ b/examples/industrial-embodied-ai/validate_artifacts.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Validate the committed industrial example artifacts.""" + +from __future__ import annotations + +import base64 +import hashlib +import json +from pathlib import Path +from typing import Any + +from agent_manifest import canonicalize +from cmcp_runtime.config import load_config +from cmcp_verify import ApprovedHashes, verify_audit_bundle, verify_trace_claim +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + +BASE = Path(__file__).resolve().parent + + +class ArtifactValidationError(Exception): + """A committed artifact failed validation.""" + + +def require(condition: object, message: str) -> None: + # Not `assert`: python -O strips asserts, and every check here would pass. + if not condition: + raise ArtifactValidationError(message) + + +def canonical_bytes(value: Any) -> bytes: + return canonicalize(value) + + +def hash_bytes(value: bytes) -> str: + return "sha256:" + hashlib.sha256(value).hexdigest() + + +def b64url_decode(value: str) -> bytes: + return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4)) + + +def external_evidence_keys() -> dict[str, bytes]: + key_doc = json.loads( + (BASE / "controller-receipt-public-key.json").read_text() + ) + key_id = key_doc["key_id"] + return {key_id: b64url_decode(key_doc["public_key_base64url"])} + + +def compute_policy_bundle_hash() -> str: + policy_dir = BASE / "policy" + manifest = json.loads((policy_dir / "manifest.json").read_text()) + policy_hashes = { + path.relative_to(policy_dir).as_posix(): hashlib.sha256( + path.read_text().encode() + ).hexdigest() + for path in sorted(policy_dir.glob("**/*.cedar")) + } + body = { + "manifest": manifest, + "policy_files": policy_hashes, + "schema_hash": hashlib.sha256( + (policy_dir / "schema.cedarschema").read_text().encode() + ).hexdigest(), + } + return hash_bytes(canonical_bytes(body)) + + +def compute_cmcp_catalog_hash(catalog: list[dict[str, Any]]) -> str: + return hash_bytes( + canonical_bytes(sorted(catalog, key=lambda entry: entry["tool_name"])) + ) + + +def compute_manifest_catalog_root(tools: list[dict[str, Any]]) -> str: + leaves = [] + for tool in sorted(tools, key=lambda item: item["tool_id"]): + preimage = ( + tool["tool_id"].encode() + + b"\x00" + + bytes.fromhex(tool["schema_hash"].split(":", maxsplit=1)[1]) + + bytes.fromhex(tool["description_hash"].split(":", maxsplit=1)[1]) + ) + leaves.append(hashlib.sha256(b"\x00" + preimage).digest()) + + def merkle_tree_hash(nodes: list[bytes]) -> bytes: + if len(nodes) == 1: + return nodes[0] + split = 1 + while split < len(nodes): + split <<= 1 + split >>= 1 + return hashlib.sha256( + b"\x01" + + merkle_tree_hash(nodes[:split]) + + merkle_tree_hash(nodes[split:]) + ).digest() + + return "sha256:" + merkle_tree_hash(leaves).hex() + + +def verify_manifest_signature(manifest: dict[str, Any]) -> None: + public_key = json.loads((BASE / "manifest-public-key.json").read_text()) + require( + manifest["signature"]["key_id"] == public_key["key_id"], + "manifest signed by an unexpected key", + ) + signed_fields = manifest["signature"]["signed_fields"] + # signed_fields travels inside the manifest, so a field it leaves out is + # unauthenticated. Every top-level field except the signature must be listed. + unsigned = set(manifest) - {"signature"} - set(signed_fields) + require(not unsigned, f"manifest fields outside the signature: {sorted(unsigned)}") + body = {key: manifest[key] for key in signed_fields if key in manifest} + Ed25519PublicKey.from_public_bytes( + b64url_decode(public_key["public_key_base64url"]) + ).verify( + b64url_decode(manifest["signature"]["signature_value"]), + canonical_bytes(body), + ) + + +def main() -> None: + expected = json.loads((BASE / "artifact-hashes.json").read_text()) + catalog = json.loads((BASE / "catalog.json").read_text()) + manifest = json.loads((BASE / "agent-manifest.json").read_text()) + claim = json.loads( + (BASE / "trace-output/example-trust-record.json").read_text() + ) + audit_bundle = json.loads( + (BASE / "trace-output/example-audit-bundle.json").read_text() + ) + + load_config(BASE / "cmcp-config.yaml") + + for entry in catalog: + definition_hash = hash_bytes( + canonical_bytes(entry["approved_definition"]) + ) + require( + definition_hash == entry["definition_hash"], + f"catalog definition_hash mismatch: {entry['tool_name']}", + ) + + policy_hash = compute_policy_bundle_hash() + catalog_hash = compute_cmcp_catalog_hash(catalog) + manifest_catalog_root = compute_manifest_catalog_root( + manifest["artifacts"]["tool_manifest"]["tools"] + ) + prompt_hash = hash_bytes( + (BASE / "artifacts/system-prompt.txt").read_bytes() + ) + + require( + expected["cmcp_policy_bundle_hash"] == policy_hash, + "policy bundle hash mismatch", + ) + require(expected["cmcp_catalog_hash"] == catalog_hash, "cMCP catalog hash mismatch") + require( + expected["agent_manifest_tool_catalog_root"] == manifest_catalog_root, + "tool catalog root mismatch", + ) + require(expected["system_prompt_hash"] == prompt_hash, "system prompt hash mismatch") + require( + manifest["artifacts"]["policy_bundle"]["hash"] == policy_hash, + "manifest policy bundle hash mismatch", + ) + require( + manifest["artifacts"]["tool_manifest"]["catalog_hash"] + == manifest_catalog_root, + "manifest tool catalog hash mismatch", + ) + require( + manifest["artifacts"]["system_prompt"]["hash"] == prompt_hash, + "manifest system prompt hash mismatch", + ) + verify_manifest_signature(manifest) + + verification = verify_trace_claim( + claim, + ApprovedHashes( + policy_bundle_hash=policy_hash, + tool_catalog_hash=catalog_hash, + ), + # The committed fixture is expected to outlive the default 24-hour + # online freshness window. Live runs still use the default window. + max_attestation_age_seconds=315_360_000, + ) + required = { + "schema", + "signature", + "policy_bundle.hash", + "tool_catalog.hash", + "attestation_freshness", + "audit_chain", + } + missing = required - set(verification.verified_fields) + require(not missing, f"TRACE claim fields not verified: {sorted(missing)}") + require( + claim["trace"]["runtime"]["platform"] == "software-only", + "committed fixture is expected to be a software-only record", + ) + + bundle_verification = verify_audit_bundle( + audit_bundle, + claim, + external_evidence_keys=external_evidence_keys(), + ) + require( + bundle_verification.verified, + f"audit bundle failed verification: {bundle_verification.failures}", + ) + receipt_count = sum( + 1 + for entry in audit_bundle.get("entries", []) + if entry.get("external_execution_evidence") + ) + + print("Configuration and artifact hashes: valid") + print("Agent Manifest signature: valid") + print("Runtime-issued TRACE signature and audit bundle: valid") + if receipt_count: + print(f"Controller execution receipts: valid ({receipt_count})") + else: + print("Controller execution receipts: not present in committed fixture") + print("Hardware attestation: not present in committed development fixture") + + +if __name__ == "__main__": + main() diff --git a/examples/multi-tenant-saas/README.md b/examples/multi-tenant-saas/README.md new file mode 100644 index 00000000..937e67cc --- /dev/null +++ b/examples/multi-tenant-saas/README.md @@ -0,0 +1,183 @@ +# multi-tenant-saas: Per-Tenant Cedar Policy Isolation + +PeopleGraph is a fictional HR / people-analytics SaaS. It serves two tenants on the same platform and the same tool catalog, but each tenant has its own data processing agreement, its own Cedar policy bundle, and its own enforcement mode in the cMCP Runtime. + +| | `metzler-eu` | `summit-us` | +|---|---|---| +| Employer | Metzler Retail GmbH (EU) | Summit Brands Inc (US) | +| Contract | EU data residency, declared legal basis, no special-category processing | US regions allowed, no legal-basis requirement | +| Enforcement | `enforcing` (violations block) | `advisory` (violations logged, not blocked) | +| Policy version | `metzler-eu-v1.0` | `summit-us-v1.0` | + +The **same four tool calls** produce different outcomes per tenant: + +| Call | `metzler-eu` (enforcing) | `summit-us` (advisory) | +|---|---|---| +| `people.headcount_analytics` | allow | allow | +| `people.employee_record_lookup` (with legal basis) | allow | allow | +| `people.data_export` to `us-east-1` | **deny** (GDPR data residency) | allow | +| `people.employee_record_lookup` special-category | **deny** (GDPR Art. 9) | advisory_deny (logged) | + +--- + +## What the demo shows + +**1. Policy-as-isolation at the tool boundary** +Each tenant has its own Cedar bundle under `tenants//policy/` and its own runtime config pointing at it. The `trace.policy.version` field in each TRACE record identifies exactly which tenant policy was enforced. + +**2. The tenants differ by real contract terms, not just a flag** +Metzler's EU processing agreement is encoded as three GDPR guardrails: a declared legal basis for personal-data processing (Art. 6), EEA data residency (Art. 44-45), and no special-category processing through the agent (Art. 9). Summit's US agreement carries none of those; it only flags special-category access for review. The bundles genuinely differ. + +**3. Progressive posture via enforcement mode** +Metzler runs `enforcing`: a matched forbid blocks the call. Summit runs `advisory`: the same match is recorded in the audit chain and surfaced as `would_have_denied` + advice in the response metadata, but the call proceeds. The advisory record still counts the matched forbid in `gateway.call_summary.tool_calls_denied`, so an auditor sees what *would* have been blocked. + +--- + +## File layout + +``` +multi-tenant-saas/ + cmcp-config-metzler-eu.yaml enforcing -> tenants/metzler-eu/policy + cmcp-config-summit-us.yaml advisory -> tenants/summit-us/policy + catalog.json shared four-tool catalog + people_directory.py employee fixtures + tool logic (one source of truth) + tenants/ + metzler-eu/policy/ metzler-eu-v1.0 (GDPR guardrails, enforced) + summit-us/policy/ summit-us-v1.0 (permissive, advisory) + server/mock_mcp_server.py mock PeopleGraph MCP server (stdlib only) + agent/saas_agent.py demo agent (run this) + tests/test_people_directory.py unit tests + trace-output/ + metzler-eu-example.json real captured TRACE record (enforcing) + summit-us-example.json real captured TRACE record (advisory) +``` + +--- + +## The catalog + +| Tool | compliance_domain | sensitivity_level | +|---|---|---| +| `people.headcount_analytics` | internal | public | +| `people.employee_record_lookup` | pii | confidential | +| `people.data_export` | pii | confidential | +| `people.config_update` | internal | public | + +`people.data_export` and `people.employee_record_lookup` accept a `legal_basis` argument, `data_export` a `destination_region`, and the lookup an `include_special_category` flag. The Cedar guardrails act on those arguments. + +--- + +## Run it + +```bash +git clone https://github.com/agentrust-io/integrations.git +cd integrations/examples +pip install cmcp-runtime httpx +``` + +**Terminal 1 - mock MCP server:** + +```bash +cd multi-tenant-saas +python server/mock_mcp_server.py +``` + +**Terminal 2 - runtime with Metzler's (EU) policy** (run from inside `multi-tenant-saas/`): + +```bash +cd multi-tenant-saas +CMCP_DEV_MODE=1 cmcp start --config cmcp-config-metzler-eu.yaml +``` + +**Terminal 3 - agent:** + +```bash +cd integrations/examples +python multi-tenant-saas/agent/saas_agent.py --tenant metzler-eu +``` + +``` +[3/4] people.data_export (scope=engineering, destination_region=us-east-1) + -> decision: deny (POLICY_DENY) + advice from policy: + id: data-residency-eea + reason: eea-data-residency-required + regulation: gdpr-art-44 +[4/4] people.employee_record_lookup (employee_id=EMP-DE-4821, include_special_category=True) + -> decision: deny (POLICY_DENY) + advice from policy: + id: special-category-block + reason: special-category-processing-prohibited + regulation: gdpr-art-9 +``` + +**Switch tenants** - stop the runtime (Ctrl-C) and restart with Summit's (US) config: + +```bash +CMCP_DEV_MODE=1 cmcp start --config cmcp-config-summit-us.yaml +python multi-tenant-saas/agent/saas_agent.py --tenant summit-us +``` + +``` +[3/4] people.data_export (scope=engineering, destination_region=us-east-1) + -> decision: allow +[4/4] people.employee_record_lookup (employee_id=EMP-DE-4821, include_special_category=True) + -> decision: advisory_deny (logged, not blocked) + advice from policy: + id: special-category-review + reason: special-category-access-flagged-for-review + regulation: us-state-privacy +``` + +Each run ends by closing the session and printing the signed TRACE Trust Record. Compare `trace.policy.version`, `trace.policy.enforcement_mode` and `gateway.call_summary.tool_calls_denied` across the two captured examples in `trace-output/`. + +--- + +## How the policies differ + +**Metzler (`tenants/metzler-eu/policy/allow.cedar`, enforcing):** aggregate analytics are open; individual lookups and exports are gated on the `people-analytics` workflow; and three GDPR forbids block the call when a control fails: + +```cedar +@id("data-residency-eea") +@reason("eea-data-residency-required") +@regulation("gdpr-art-44") +forbid ( + principal, + action == Action::"People.dataExport", + resource +) when { + context.arguments has destination_region && + !(["eu-central-1", "eu-west-1", "eu-north-1", "eu-west-3"].contains(context.arguments.destination_region)) +}; +``` + +**Summit (`tenants/summit-us/policy/allow.cedar`, advisory):** a catch-all permit plus a single forbid that flags special-category access for review. Under advisory mode this logs and surfaces advice but does not block. + +Action names follow the cMCP convention: `people.data_export` becomes `Action::"People.dataExport"` (the segment before the dot PascalCase, each underscore segment after it camelCase). Tool arguments are available under `context.arguments`; the `@annotation` values are returned in `error.data.advice` for hard denies and in `_cmcp.advice` for advisory ones. + +--- + +## Running both tenants simultaneously + +This is the production topology: one attested runtime instance per tenant isolation boundary, each measuring its own policy bundle hash into its TRACE records. Run two runtimes on different ports (edit `listen_addr` in one config) against the same mock server: + +```bash +python multi-tenant-saas/agent/saas_agent.py --tenant metzler-eu --gateway http://localhost:8443 +python multi-tenant-saas/agent/saas_agent.py --tenant summit-us --gateway http://localhost:9443 +``` + +--- + +## The tests + +`tests/test_people_directory.py` checks that headcount output is aggregate-only, that special-category is a request flag rather than emitted data, and that EEA region classification and per-region currency are correct. + +```bash +python -m unittest discover -s tests -v +``` + +--- + +## License + +Apache 2.0. See [LICENSE](../LICENSE) in the repo root. diff --git a/examples/multi-tenant-saas/agent/saas_agent.py b/examples/multi-tenant-saas/agent/saas_agent.py new file mode 100644 index 00000000..81aac23f --- /dev/null +++ b/examples/multi-tenant-saas/agent/saas_agent.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +""" +PeopleGraph agent demo showing per-tenant Cedar policy isolation. + +Runs the same four tool calls; the outcome depends on which tenant's policy +bundle and enforcement mode the runtime was started with: + + metzler-eu (EU, enforcing): + headcount allow, employee lookup allow, cross-region export DENY + (GDPR data residency), special-category lookup DENY (GDPR Art. 9) + summit-us (US, advisory): + headcount allow, employee lookup allow, export allow, + special-category lookup advisory_deny (logged, not blocked) + +Usage: + python saas_agent.py --tenant metzler-eu|summit-us [--gateway http://localhost:8443] +""" + +import argparse +import json +import sys +import httpx + +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + +DEFAULT_GATEWAY = "http://localhost:8443" +WORKFLOW_ID = "people-analytics" + +# The same four calls run against whichever tenant policy the runtime loaded. +CALLS = [ + ("people.headcount_analytics", + {"metric": "attrition", "period": "2026-Q2"}), + ("people.employee_record_lookup", + {"employee_id": "EMP-DE-4821", "legal_basis": "legitimate_interest"}), + ("people.data_export", + {"scope": "engineering", "destination_region": "us-east-1", "legal_basis": "legitimate_interest"}), + ("people.employee_record_lookup", + {"employee_id": "EMP-DE-4821", "include_special_category": True, "legal_basis": "legitimate_interest"}), +] + + +def call_tool(client, gateway, tool_name, arguments, req_id): + payload = { + "jsonrpc": "2.0", + "id": req_id, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments, + "_cmcp": {"workflow_id": WORKFLOW_ID}, + }, + } + resp = client.post(f"{gateway}/mcp", json=payload, timeout=30) + body = resp.json() + if "error" in body: + return {"ok": False, "error": body["error"], "session_id": None} + result = body["result"] + return {"ok": True, "result": result, "session_id": result.get("_cmcp", {}).get("session_id")} + + +def print_outcome(outcome): + if outcome["ok"]: + meta = outcome["result"].get("_cmcp", {}) + if meta.get("would_have_denied"): + print(" -> decision: advisory_deny (logged, not blocked)") + _print_advice(meta.get("advice")) + else: + print(" -> decision: allow") + else: + data = outcome["error"].get("data", {}) + print(f" -> decision: deny ({data.get('error_code', 'unknown')})") + _print_advice(data.get("advice")) + + +def _print_advice(advice): + if advice: + print(" advice from policy:") + for key, value in advice.items(): + print(f" {key}: {value}") + + +def close_session(client, gateway, session_id): + resp = client.post(f"{gateway}/sessions/{session_id}/close", timeout=10) + resp.raise_for_status() + return resp.json() + + +def run(gateway, tenant): + print(f"Connecting to cMCP Runtime at {gateway}") + print(f"Tenant: {tenant}") + print(f"Workflow: {WORKFLOW_ID}") + print() + print(f"Running the same {len(CALLS)} tool calls against {tenant}'s policy bundle.") + print() + + session_id = None + with httpx.Client(headers={"Content-Type": "application/json"}) as client: + for i, (tool, args) in enumerate(CALLS, start=1): + summary = ", ".join(f"{k}={v}" for k, v in args.items() if k != "legal_basis") + print(f"[{i}/{len(CALLS)}] {tool} ({summary})") + o = call_tool(client, gateway, tool, args, i) + print_outcome(o) + session_id = o.get("session_id") or session_id + + print() + if session_id is None: + print("No session id received - cannot fetch TRACE Trust Record.") + sys.exit(1) + + print(f"Closing session {session_id} and fetching the signed TRACE Trust Record ...") + claim = close_session(client, gateway, session_id) + print() + print("=== TRACE Trust Record (signed RuntimeClaim) ===") + print(json.dumps(claim, indent=2)) + + print() + print("Done. Restart the runtime with the other tenant config to see the policy difference.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="PeopleGraph multi-tenant policy isolation demo") + parser.add_argument("--gateway", default=DEFAULT_GATEWAY, + help=f"cMCP Runtime base URL (default: {DEFAULT_GATEWAY})") + parser.add_argument("--tenant", required=True, choices=["metzler-eu", "summit-us"], + help="Which tenant config the runtime was started with") + args = parser.parse_args() + run(args.gateway, args.tenant) diff --git a/examples/multi-tenant-saas/catalog.json b/examples/multi-tenant-saas/catalog.json new file mode 100644 index 00000000..d0dcb5cc --- /dev/null +++ b/examples/multi-tenant-saas/catalog.json @@ -0,0 +1,137 @@ +[ + { + "tool_name": "people.headcount_analytics", + "server": { + "display_name": "PeopleGraph MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Run a read-only, aggregate people-analytics query (no individual records)", + "input_schema": { + "type": "object", + "properties": { + "metric": { + "type": "string" + }, + "period": { + "type": "string" + } + }, + "required": [ + "metric" + ] + } + }, + "definition_hash": "sha256:08676333bb67c406a83409a0c806535b6431685b49898e3881a5982bae94a252", + "compliance_domain": "internal", + "requires_baa": false, + "sensitivity_level": "public", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "platform-security@peoplegraph.example" + }, + { + "tool_name": "people.employee_record_lookup", + "server": { + "display_name": "PeopleGraph MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Look up an individual employee record", + "input_schema": { + "type": "object", + "properties": { + "employee_id": { + "type": "string" + }, + "legal_basis": { + "type": "string" + }, + "include_special_category": { + "type": "boolean" + } + }, + "required": [ + "employee_id" + ] + } + }, + "definition_hash": "sha256:1bb7cce8b5654794544ac88a146c30be7802a9873f9bd3c3e44963552eab6bf0", + "compliance_domain": "pii", + "requires_baa": false, + "sensitivity_level": "confidential", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "platform-security@peoplegraph.example" + }, + { + "tool_name": "people.data_export", + "server": { + "display_name": "PeopleGraph MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Export employee records to a destination region", + "input_schema": { + "type": "object", + "properties": { + "scope": { + "type": "string" + }, + "destination_region": { + "type": "string" + }, + "legal_basis": { + "type": "string" + } + }, + "required": [ + "destination_region" + ] + } + }, + "definition_hash": "sha256:9cf09bc74122256f4fdd6a8d8f6623b085936cce3cc95af5ea993343ab5a577e", + "compliance_domain": "pii", + "requires_baa": false, + "sensitivity_level": "confidential", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "platform-security@peoplegraph.example" + }, + { + "tool_name": "people.config_update", + "server": { + "display_name": "PeopleGraph MCP Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Update tenant configuration settings", + "input_schema": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "key", + "value" + ] + } + }, + "definition_hash": "sha256:1761ee6862e8509983983543450a6bda6ea1e91536492daeb8d11b0b380e163c", + "compliance_domain": "internal", + "requires_baa": false, + "sensitivity_level": "public", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "platform-security@peoplegraph.example" + } +] diff --git a/examples/multi-tenant-saas/cmcp-config-metzler-eu.yaml b/examples/multi-tenant-saas/cmcp-config-metzler-eu.yaml new file mode 100644 index 00000000..b585b086 --- /dev/null +++ b/examples/multi-tenant-saas/cmcp-config-metzler-eu.yaml @@ -0,0 +1,9 @@ +policy_bundle_path: ./tenants/metzler-eu/policy +catalog_path: ./catalog.json +listen_addr: 127.0.0.1:8443 +audit_db_path: ./audit-metzler-eu.db +attestation: + provider: auto + # Metzler (EU) runs enforcing: GDPR guardrails block the call. + enforcement_mode: enforcing + validity_seconds: 86400 diff --git a/examples/multi-tenant-saas/cmcp-config-summit-us.yaml b/examples/multi-tenant-saas/cmcp-config-summit-us.yaml new file mode 100644 index 00000000..3883605b --- /dev/null +++ b/examples/multi-tenant-saas/cmcp-config-summit-us.yaml @@ -0,0 +1,10 @@ +policy_bundle_path: ./tenants/summit-us/policy +catalog_path: ./catalog.json +listen_addr: 127.0.0.1:8443 +audit_db_path: ./audit-summit-us.db +attestation: + provider: auto + # Summit (US) runs advisory: a matched forbid is logged and surfaced as + # would_have_denied + advice, but the call is not blocked. + enforcement_mode: advisory + validity_seconds: 86400 diff --git a/examples/multi-tenant-saas/people_directory.py b/examples/multi-tenant-saas/people_directory.py new file mode 100644 index 00000000..dbcc0317 --- /dev/null +++ b/examples/multi-tenant-saas/people_directory.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" +People-directory domain logic for the multi-tenant-saas example. + +PeopleGraph is a fictional HR / people-analytics SaaS. This module holds the +employee fixtures and the pure functions the mock MCP server serves, so the +server, the tests and the agent agree on one source of truth. No dependencies. + +The data is deliberately free of phone numbers, e-mail addresses and other +patterns a response scanner would flag, so the demo turns purely on the +per-tenant policy difference rather than on incidental PII detections. +""" + +from __future__ import annotations + +from typing import Any + +# Regions that count as inside the EEA for data-residency purposes. +EEA_REGIONS = ["eu-central-1", "eu-west-1", "eu-north-1", "eu-west-3"] + +EMPLOYEES: dict[str, dict[str, Any]] = { + "EMP-DE-4821": { + "name": "Katharina Vogel", + "role": "Senior Data Engineer", + "department": "Engineering", + "location": "Munich, DE", + "region": "eu-central-1", + "manager_id": "EMP-DE-3300", + "comp_band": "L5", + "base_salary_eur": 96_000, + "tenure_years": 4.2, + }, + "EMP-DE-3300": { + "name": "Stefan Brandt", + "role": "Engineering Manager", + "department": "Engineering", + "location": "Munich, DE", + "region": "eu-central-1", + "manager_id": None, + "comp_band": "M2", + "base_salary_eur": 128_000, + "tenure_years": 7.1, + }, + "EMP-US-1099": { + "name": "Marcus Bell", + "role": "Product Manager", + "department": "Product", + "location": "Columbus, US", + "region": "us-east-1", + "manager_id": "EMP-US-1000", + "comp_band": "L5", + "base_salary_usd": 132_000, + "tenure_years": 3.4, + }, +} + +DEFAULT_EMPLOYEE = "EMP-DE-4821" + + +def headcount_analytics(metric: str = "attrition", period: str = "2026-Q2") -> dict[str, Any]: + """Aggregate, non-identifying people metrics.""" + by_department: dict[str, int] = {} + for emp in EMPLOYEES.values(): + by_department[emp["department"]] = by_department.get(emp["department"], 0) + 1 + return { + "metric": metric, + "period": period, + "headcount_total": len(EMPLOYEES), + "headcount_by_department": by_department, + "attrition_rate": 0.081, + "voluntary_share": 0.63, + "status": "completed", + } + + +def employee_record_lookup(employee_id: str, include_special_category: bool = False) -> dict[str, Any]: + """Return a single employee record. + + Special-category data (GDPR Art. 9) is never emitted here; access to it is a + request attribute the policy decides on, so the sensitive fields do not flow + at all when a tenant forbids them. + """ + emp = EMPLOYEES.get(employee_id, EMPLOYEES[DEFAULT_EMPLOYEE]) + record = { + "employee_id": employee_id, + "name": emp["name"], + "role": emp["role"], + "department": emp["department"], + "location": emp["location"], + "region": emp["region"], + "manager_id": emp["manager_id"], + "comp_band": emp["comp_band"], + "tenure_years": emp["tenure_years"], + } + if "base_salary_eur" in emp: + record["base_salary_eur"] = emp["base_salary_eur"] + if "base_salary_usd" in emp: + record["base_salary_usd"] = emp["base_salary_usd"] + record["special_category_included"] = bool(include_special_category) + record["status"] = "retrieved" + return record + + +def data_export(scope: str, destination_region: str) -> dict[str, Any]: + """Export a set of employee records to a destination region.""" + matched = [e for e in EMPLOYEES.values() if scope in ("all", e["department"].lower())] + return { + "scope": scope, + "destination_region": destination_region, + "destination_in_eea": destination_region in EEA_REGIONS, + "records_exported": len(matched) or len(EMPLOYEES), + "export_id": "EXP-2026-00917", + "status": "exported", + } + + +def config_update(key: str, value: str) -> dict[str, Any]: + return { + "key": key, + "value": value, + "previous_value": "30", + "status": "updated", + } diff --git a/examples/multi-tenant-saas/server/mock_mcp_server.py b/examples/multi-tenant-saas/server/mock_mcp_server.py new file mode 100644 index 00000000..5ed924e6 --- /dev/null +++ b/examples/multi-tenant-saas/server/mock_mcp_server.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +""" +Mock PeopleGraph MCP Server for the multi-tenant-saas demo. + +Serves the four catalog tools on port 8080. Tool responses come from +``people_directory`` so the server, the tests and the agent share one source +of truth. Stdlib only -- no dependencies. + +Usage: + python multi-tenant-saas/server/mock_mcp_server.py +""" + +import json +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EXAMPLE_DIR)) + +import people_directory # noqa: E402 + +PORT = 8080 + + +def _headcount_analytics(args: dict) -> str: + return json.dumps(people_directory.headcount_analytics( + args.get("metric", "attrition"), args.get("period", "2026-Q2"))) + + +def _employee_record_lookup(args: dict) -> str: + return json.dumps(people_directory.employee_record_lookup( + args.get("employee_id", people_directory.DEFAULT_EMPLOYEE), + bool(args.get("include_special_category", False)))) + + +def _data_export(args: dict) -> str: + return json.dumps(people_directory.data_export( + args.get("scope", "all"), args.get("destination_region", ""))) + + +def _config_update(args: dict) -> str: + return json.dumps(people_directory.config_update( + args.get("key", ""), args.get("value", ""))) + + +TOOLS = { + "people.headcount_analytics": _headcount_analytics, + "people.employee_record_lookup": _employee_record_lookup, + "people.data_export": _data_export, + "people.config_update": _config_update, +} + + +# Upstream definitions are independent of the gateway catalog used for drift checks. +TOOL_DEFINITIONS = [ + { + "name": "people.headcount_analytics", + "description": "Run a read-only, aggregate people-analytics query (no individual records)", + "inputSchema": { + "type": "object", + "properties": {"metric": {"type": "string"}, "period": {"type": "string"}}, + "required": ["metric"], + }, + }, + { + "name": "people.employee_record_lookup", + "description": "Look up an individual employee record", + "inputSchema": { + "type": "object", + "properties": { + "employee_id": {"type": "string"}, + "legal_basis": {"type": "string"}, + "include_special_category": {"type": "boolean"}, + }, + "required": ["employee_id"], + }, + }, + { + "name": "people.data_export", + "description": "Export employee records to a destination region", + "inputSchema": { + "type": "object", + "properties": { + "scope": {"type": "string"}, + "destination_region": {"type": "string"}, + "legal_basis": {"type": "string"}, + }, + "required": ["destination_region"], + }, + }, + { + "name": "people.config_update", + "description": "Update tenant configuration settings", + "inputSchema": { + "type": "object", + "properties": {"key": {"type": "string"}, "value": {"type": "string"}}, + "required": ["key", "value"], + }, + }, +] + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): # noqa: N802 + if self.path != "/mcp": + self._reply(404, {"error": "not found"}) + return + length = int(self.headers.get("Content-Length", 0)) + request = json.loads(self.rfile.read(length)) + method = request.get("method", "") + if method == "tools/list": + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "result": {"tools": TOOL_DEFINITIONS}, + }) + return + if method != "tools/call": + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "error": {"code": -32601, "message": f"unknown method: {method}"}, + }) + return + params = request.get("params", {}) + tool = params.get("name", "") + handler = TOOLS.get(tool) + if handler is None: + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "error": {"code": -32601, "message": f"unknown tool: {tool}"}, + }) + return + text = handler(params.get("arguments", {})) + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "result": {"content": [{"type": "text", "text": text}]}, + }) + + def _reply(self, status: int, body: dict) -> None: + payload = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, fmt, *args): + print(f"[mock-peoplegraph] {fmt % args}") + + +if __name__ == "__main__": + print(f"Mock PeopleGraph MCP Server listening on :{PORT} (tools: {', '.join(TOOLS)})") + HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/examples/multi-tenant-saas/tenants/metzler-eu/policy/allow.cedar b/examples/multi-tenant-saas/tenants/metzler-eu/policy/allow.cedar new file mode 100644 index 00000000..c6b72c9e --- /dev/null +++ b/examples/multi-tenant-saas/tenants/metzler-eu/policy/allow.cedar @@ -0,0 +1,83 @@ +// Cedar policy bundle for the Metzler Retail GmbH (EU) tenant of PeopleGraph. +// version: metzler-eu-v1.0 +// +// Metzler is an EU employer. Its data processing agreement requires EU data +// residency, a declared legal basis for processing personal data, and no +// processing of special-category data through the analytics agent. The tenant +// runs in ENFORCING mode, so these controls block the call. +// +// Action names follow the cMCP convention: the segment before the dot is +// PascalCase and each underscore segment after it is camelCase. + +// Aggregate, non-identifying analytics are open to any workflow. +permit ( + principal, + action == Action::"People.headcountAnalytics", + resource +); + +// Individual records and exports are limited to the people-analytics workflow. +permit ( + principal, + action == Action::"People.employeeRecordLookup", + resource +) when { + context has workflow_id && context.workflow_id == "people-analytics" +}; + +permit ( + principal, + action == Action::"People.dataExport", + resource +) when { + context has workflow_id && context.workflow_id == "people-analytics" +}; + +// Configuration changes require the admin workflow. +permit ( + principal, + action == Action::"People.configUpdate", + resource +) when { + context has workflow_id && context.workflow_id == "admin-workflow" +}; + +// --- GDPR guardrails (enforced) ---------------------------------------------- + +// Processing personal data requires a declared legal basis (GDPR Art. 6). +@id("legal-basis-required") +@reason("gdpr-legal-basis-required") +@regulation("gdpr-art-6") +forbid ( + principal, + action in [Action::"People.employeeRecordLookup", Action::"People.dataExport"], + resource +) when { + !(context.arguments has legal_basis) +}; + +// Personal data must stay in the EEA (GDPR Chapter V, Art. 44-45). +@id("data-residency-eea") +@reason("eea-data-residency-required") +@regulation("gdpr-art-44") +forbid ( + principal, + action == Action::"People.dataExport", + resource +) when { + context.arguments has destination_region && + !(["eu-central-1", "eu-west-1", "eu-north-1", "eu-west-3"].contains(context.arguments.destination_region)) +}; + +// Special-category data (GDPR Art. 9) must not flow through the analytics agent. +@id("special-category-block") +@reason("special-category-processing-prohibited") +@regulation("gdpr-art-9") +forbid ( + principal, + action == Action::"People.employeeRecordLookup", + resource +) when { + context.arguments has include_special_category && + context.arguments.include_special_category == true +}; diff --git a/examples/multi-tenant-saas/tenants/metzler-eu/policy/manifest.json b/examples/multi-tenant-saas/tenants/metzler-eu/policy/manifest.json new file mode 100644 index 00000000..561a4730 --- /dev/null +++ b/examples/multi-tenant-saas/tenants/metzler-eu/policy/manifest.json @@ -0,0 +1,6 @@ +{ + "version": "metzler-eu-v1.0", + "authored_at": "2026-06-01T00:00:00Z", + "author_identity": "dpo@metzler-retail.example", + "commit_sha": "eu11tenant0001" +} diff --git a/examples/multi-tenant-saas/tenants/metzler-eu/policy/schema.cedarschema b/examples/multi-tenant-saas/tenants/metzler-eu/policy/schema.cedarschema new file mode 100644 index 00000000..889ad3f5 --- /dev/null +++ b/examples/multi-tenant-saas/tenants/metzler-eu/policy/schema.cedarschema @@ -0,0 +1 @@ +{"cMCP": {}} diff --git a/examples/multi-tenant-saas/tenants/summit-us/policy/allow.cedar b/examples/multi-tenant-saas/tenants/summit-us/policy/allow.cedar new file mode 100644 index 00000000..2cef55c3 --- /dev/null +++ b/examples/multi-tenant-saas/tenants/summit-us/policy/allow.cedar @@ -0,0 +1,29 @@ +// Cedar policy bundle for the Summit Brands Inc (US) tenant of PeopleGraph. +// version: summit-us-v1.0 +// +// Summit is a US employer with a lighter contractual posture: US data regions +// are acceptable and a declared legal basis is not required. The tenant runs +// in ADVISORY mode, so a matched forbid is logged in the audit chain and +// surfaced as would_have_denied + advice, but the call still proceeds. This is +// the same platform and catalog as the EU tenant; only the policy bundle and +// the enforcement mode differ. + +// Catch-all permit: the baseline posture is permissive. +permit ( + principal, + action, + resource +); + +// Special-category access is flagged for privacy review (advisory only). +@id("special-category-review") +@reason("special-category-access-flagged-for-review") +@regulation("us-state-privacy") +forbid ( + principal, + action == Action::"People.employeeRecordLookup", + resource +) when { + context.arguments has include_special_category && + context.arguments.include_special_category == true +}; diff --git a/examples/multi-tenant-saas/tenants/summit-us/policy/manifest.json b/examples/multi-tenant-saas/tenants/summit-us/policy/manifest.json new file mode 100644 index 00000000..adbaba85 --- /dev/null +++ b/examples/multi-tenant-saas/tenants/summit-us/policy/manifest.json @@ -0,0 +1,6 @@ +{ + "version": "summit-us-v1.0", + "authored_at": "2026-06-01T00:00:00Z", + "author_identity": "security@summit-brands.example", + "commit_sha": "us11tenant0001" +} diff --git a/examples/multi-tenant-saas/tenants/summit-us/policy/schema.cedarschema b/examples/multi-tenant-saas/tenants/summit-us/policy/schema.cedarschema new file mode 100644 index 00000000..889ad3f5 --- /dev/null +++ b/examples/multi-tenant-saas/tenants/summit-us/policy/schema.cedarschema @@ -0,0 +1 @@ +{"cMCP": {}} diff --git a/examples/multi-tenant-saas/tests/test_people_directory.py b/examples/multi-tenant-saas/tests/test_people_directory.py new file mode 100644 index 00000000..bb5435bd --- /dev/null +++ b/examples/multi-tenant-saas/tests/test_people_directory.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +EXAMPLE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(EXAMPLE_DIR)) + +import people_directory as pd # noqa: E402 + + +class PeopleDirectoryTests(unittest.TestCase): + def test_headcount_is_aggregate_only(self) -> None: + h = pd.headcount_analytics() + self.assertEqual(h["headcount_total"], len(pd.EMPLOYEES)) + # aggregate output must not leak individual names + self.assertNotIn("name", h) + self.assertIn("Engineering", h["headcount_by_department"]) + + def test_lookup_returns_the_record(self) -> None: + r = pd.employee_record_lookup("EMP-DE-4821") + self.assertEqual(r["name"], "Katharina Vogel") + self.assertEqual(r["region"], "eu-central-1") + self.assertEqual(r["base_salary_eur"], 96_000) + + def test_special_category_is_a_request_flag_not_data(self) -> None: + r = pd.employee_record_lookup("EMP-DE-4821", include_special_category=True) + self.assertTrue(r["special_category_included"]) + # no actual special-category field is ever emitted + for banned in ("health", "religion", "union", "ethnicity"): + self.assertNotIn(banned, r) + + def test_eea_region_classification(self) -> None: + self.assertTrue(pd.data_export("all", "eu-central-1")["destination_in_eea"]) + self.assertFalse(pd.data_export("all", "us-east-1")["destination_in_eea"]) + + def test_us_employee_uses_usd(self) -> None: + r = pd.employee_record_lookup("EMP-US-1099") + self.assertIn("base_salary_usd", r) + self.assertNotIn("base_salary_eur", r) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/multi-tenant-saas/trace-output/metzler-eu-example.json b/examples/multi-tenant-saas/trace-output/metzler-eu-example.json new file mode 100644 index 00000000..1981e3a1 --- /dev/null +++ b/examples/multi-tenant-saas/trace-output/metzler-eu-example.json @@ -0,0 +1,103 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1784649554, + "subject": "spiffe://cmcp.gateway/tee/fd8342dc4faa80a9", + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:81a00dbfbdf8f5fc53069f6c82392a51babbedf7d4bc4e9d5fcd7849ef061a35", + "enforcement_mode": "enforce", + "version": "metzler-eu-v1.0" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:e87986d2c821c05e08dc01ceab47a56920b66fc67d9178b41d41431db41c4aa7", + "call_count": 4, + "entries": [ + { + "tool_name": "people.headcount_analytics", + "data_class": "public", + "decision": "allow" + }, + { + "tool_name": "people.employee_record_lookup", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "people.data_export", + "data_class": "confidential", + "decision": "deny" + }, + { + "tool_name": "people.employee_record_lookup", + "data_class": "confidential", + "decision": "deny" + } + ] + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "_YNC3E-qgKmAnqsg_QnV-xev3NqIseeZtdK7CH7ryuE", + "kid": "cmcp-fd8342dc" + } + } + }, + "gateway": { + "session_id": "808fb892-f05e-4760-bca3-44213857ae29", + "gateway_version": "0.3.0", + "sequence_number": 2, + "prev_claim_hash": "sha256:76557f67a337ae2a13902c838c30599bfca2b5238c9c33166431b3866cac9c5c", + "audit_chain": { + "root": "b874563c59ae2a312c82ad3c8f62314fe9365c2dd46a530e00dfb990fc1fa33f", + "tip": "e87986d2c821c05e08dc01ceab47a56920b66fc67d9178b41d41431db41c4aa7", + "length": 6 + }, + "call_summary": { + "tool_calls_total": 4, + "tool_calls_allowed": 2, + "tool_calls_denied": 2, + "tool_calls_faulted": 0, + "tools_invoked": [ + "people.data_export", + "people.employee_record_lookup", + "people.headcount_analytics" + ], + "session_max_sensitivity": "confidential", + "call_graph_summary": { + "compliance_domains_touched": [ + "internal", + "pii" + ], + "cross_boundary_events": [], + "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." + } + }, + "catalog": { + "hash": "sha256:4e4e33266c49c74838238b669dc9b30b583c70ac9f5e1e7ad884deda6133f522", + "drift_detected": false + }, + "attestation_generated_at": "2026-07-21T15:58:30.347008+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 4, + "tools_called": [ + "people.headcount_analytics", + "people.employee_record_lookup", + "people.data_export" + ], + "suspicious_sequences_detected": 0 + }, + "kill_switch_triggered": false + }, + "signature": "y7654ieeJtl7fPtMUKQCopnMaG8Z70_M5vxGwrIaiZFeEV9jSbA99LZ9aDuID_HHCaK84ApNmWv7LDG7bt-5BQ" +} diff --git a/examples/multi-tenant-saas/trace-output/summit-us-example.json b/examples/multi-tenant-saas/trace-output/summit-us-example.json new file mode 100644 index 00000000..c5b68be8 --- /dev/null +++ b/examples/multi-tenant-saas/trace-output/summit-us-example.json @@ -0,0 +1,103 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1784649599, + "subject": "spiffe://cmcp.gateway/tee/a410b1695a747cf5", + "runtime": { + "platform": "software-only", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:2fe90e86b22e6319ba5f87cbf78971087058a80959a45b45808d9d50a1f32bc3", + "enforcement_mode": "advisory", + "version": "summit-us-v1.0" + }, + "data_class": "confidential", + "tool_transcript": { + "hash": "sha256:1eeb0e3e203eef819816027d06139328ba465ab12cb57ce76895581d456557ed", + "call_count": 4, + "entries": [ + { + "tool_name": "people.headcount_analytics", + "data_class": "public", + "decision": "allow" + }, + { + "tool_name": "people.employee_record_lookup", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "people.data_export", + "data_class": "confidential", + "decision": "allow" + }, + { + "tool_name": "people.employee_record_lookup", + "data_class": "confidential", + "decision": "advisory_deny" + } + ] + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "pBCxaVp0fPW-gWXAkB0HpkXYUsAkvTJxBqzpGdRgUuE", + "kid": "cmcp-a410b169" + } + } + }, + "gateway": { + "session_id": "44c66904-7457-464c-8f03-e0befaf2ef6b", + "gateway_version": "0.3.0", + "sequence_number": 2, + "prev_claim_hash": "sha256:ea655ef99428f704dfec3115a00f67823139e485b702d306b59b6f393dc456d8", + "audit_chain": { + "root": "b9bba95881e96e7aa11c7cfec299a99956bc5efb4144368db1226e0f481e1f38", + "tip": "1eeb0e3e203eef819816027d06139328ba465ab12cb57ce76895581d456557ed", + "length": 6 + }, + "call_summary": { + "tool_calls_total": 4, + "tool_calls_allowed": 3, + "tool_calls_denied": 1, + "tool_calls_faulted": 0, + "tools_invoked": [ + "people.data_export", + "people.employee_record_lookup", + "people.headcount_analytics" + ], + "session_max_sensitivity": "confidential", + "call_graph_summary": { + "compliance_domains_touched": [ + "internal", + "pii" + ], + "cross_boundary_events": [], + "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." + } + }, + "catalog": { + "hash": "sha256:4e4e33266c49c74838238b669dc9b30b583c70ac9f5e1e7ad884deda6133f522", + "drift_detected": false + }, + "attestation_generated_at": "2026-07-21T15:59:29.006308+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 4, + "tools_called": [ + "people.headcount_analytics", + "people.employee_record_lookup", + "people.data_export" + ], + "suspicious_sequences_detected": 0 + }, + "kill_switch_triggered": false + }, + "signature": "vahNTMpq2zPFpDbxLGDwSCimOxUuVjrrVq_cIT1Ov5jnQN5BtfD3IDMOGSoG0ZkI9rGY3k-T_fixOxRYoCbIBg" +} diff --git a/examples/requirements/controller-tests.in b/examples/requirements/controller-tests.in new file mode 100644 index 00000000..e942600b --- /dev/null +++ b/examples/requirements/controller-tests.in @@ -0,0 +1,4 @@ +# The industrial-embodied-ai controller tests. Compile with: +# uv pip compile requirements/controller-tests.in --generate-hashes --universal \ +# --python-version 3.11 -o requirements/controller-tests.txt +cryptography>=50.0.0 diff --git a/examples/requirements/controller-tests.txt b/examples/requirements/controller-tests.txt new file mode 100644 index 00000000..ccc7aec3 --- /dev/null +++ b/examples/requirements/controller-tests.txt @@ -0,0 +1,156 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements/controller-tests.in --generate-hashes --universal --python-version 3.11 -o requirements/controller-tests.txt +cffi==2.1.1 ; platform_python_implementation != 'PyPy' \ + --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 + # via cryptography +cryptography==50.0.1 \ + --hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \ + --hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \ + --hash=sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 \ + --hash=sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e \ + --hash=sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361 \ + --hash=sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054 \ + --hash=sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f \ + --hash=sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6 \ + --hash=sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49 \ + --hash=sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5 \ + --hash=sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149 \ + --hash=sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88 \ + --hash=sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad \ + --hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a \ + --hash=sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f \ + --hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 \ + --hash=sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20 \ + --hash=sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45 \ + --hash=sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f \ + --hash=sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b \ + --hash=sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527 \ + --hash=sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3 \ + --hash=sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6 \ + --hash=sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367 \ + --hash=sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0 \ + --hash=sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94 \ + --hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 \ + --hash=sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b \ + --hash=sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a \ + --hash=sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9 \ + --hash=sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5 \ + --hash=sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc \ + --hash=sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648 \ + --hash=sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986 \ + --hash=sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959 \ + --hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 \ + --hash=sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17 \ + --hash=sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e \ + --hash=sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733 \ + --hash=sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f \ + --hash=sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8 \ + --hash=sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf \ + --hash=sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671 \ + --hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \ + --hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \ + --hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef + # via -r requirements/controller-tests.in +pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi diff --git a/examples/requirements/receipts-tests.in b/examples/requirements/receipts-tests.in new file mode 100644 index 00000000..be1c4098 --- /dev/null +++ b/examples/requirements/receipts-tests.in @@ -0,0 +1,6 @@ +# The embodied-action-receipts verifier and its tests. cryptography is the only +# dependency, and nothing here is a published subject under test, so it is +# pinned like the rest of the harness. Compile with: +# uv pip compile requirements/receipts-tests.in --generate-hashes --universal \ +# --python-version 3.11 -o requirements/receipts-tests.txt +cryptography>=50.0.1 diff --git a/examples/requirements/receipts-tests.txt b/examples/requirements/receipts-tests.txt new file mode 100644 index 00000000..568df4fd --- /dev/null +++ b/examples/requirements/receipts-tests.txt @@ -0,0 +1,156 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements/receipts-tests.in --generate-hashes --universal --python-version 3.11 -o requirements/receipts-tests.txt +cffi==2.1.1 ; platform_python_implementation != 'PyPy' \ + --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 + # via cryptography +cryptography==50.0.1 \ + --hash=sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71 \ + --hash=sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23 \ + --hash=sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6 \ + --hash=sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e \ + --hash=sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361 \ + --hash=sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054 \ + --hash=sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f \ + --hash=sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6 \ + --hash=sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49 \ + --hash=sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5 \ + --hash=sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149 \ + --hash=sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88 \ + --hash=sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad \ + --hash=sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a \ + --hash=sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f \ + --hash=sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2 \ + --hash=sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20 \ + --hash=sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45 \ + --hash=sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f \ + --hash=sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b \ + --hash=sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527 \ + --hash=sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3 \ + --hash=sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6 \ + --hash=sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367 \ + --hash=sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0 \ + --hash=sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94 \ + --hash=sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239 \ + --hash=sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b \ + --hash=sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a \ + --hash=sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9 \ + --hash=sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5 \ + --hash=sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc \ + --hash=sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648 \ + --hash=sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986 \ + --hash=sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959 \ + --hash=sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0 \ + --hash=sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17 \ + --hash=sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e \ + --hash=sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733 \ + --hash=sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f \ + --hash=sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8 \ + --hash=sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf \ + --hash=sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671 \ + --hash=sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80 \ + --hash=sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558 \ + --hash=sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef + # via -r requirements/receipts-tests.in +pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi diff --git a/examples/requirements/smoke-harness.in b/examples/requirements/smoke-harness.in new file mode 100644 index 00000000..db9c35e8 --- /dev/null +++ b/examples/requirements/smoke-harness.in @@ -0,0 +1,6 @@ +# The HTTP client the cMCP smoke tests drive the gateway with. cmcp-runtime +# itself is the subject of those tests and stays resolved from PyPI. +# Compile with: +# uv pip compile requirements/smoke-harness.in --generate-hashes --universal \ +# --python-version 3.11 -o requirements/smoke-harness.txt +httpx diff --git a/examples/requirements/smoke-harness.txt b/examples/requirements/smoke-harness.txt new file mode 100644 index 00000000..f701bd84 --- /dev/null +++ b/examples/requirements/smoke-harness.txt @@ -0,0 +1,34 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements/smoke-harness.in --generate-hashes --universal --python-version 3.11 -o requirements/smoke-harness.txt +anyio==4.15.1 \ + --hash=sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101 \ + --hash=sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94 + # via httpx +certifi==2026.7.22 \ + --hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \ + --hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55 + # via + # httpcore + # httpx +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via httpcore +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via -r requirements/smoke-harness.in +idna==3.19 \ + --hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \ + --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 + # via + # anyio + # httpx +typing-extensions==4.16.0 ; python_full_version < '3.15' \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via anyio diff --git a/examples/requirements/wcm-harness.in b/examples/requirements/wcm-harness.in new file mode 100644 index 00000000..4177797d --- /dev/null +++ b/examples/requirements/wcm-harness.in @@ -0,0 +1,6 @@ +# The runner for the weight-custody demos. The SDK itself is installed +# separately and stays resolved from PyPI, because these demos exist to +# exercise the published release. Compile with: +# uv pip compile requirements/wcm-harness.in --generate-hashes --universal \ +# --python-version 3.11 -o requirements/wcm-harness.txt +pytest diff --git a/examples/requirements/wcm-harness.txt b/examples/requirements/wcm-harness.txt new file mode 100644 index 00000000..21c22466 --- /dev/null +++ b/examples/requirements/wcm-harness.txt @@ -0,0 +1,26 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements/wcm-harness.in --generate-hashes --universal --python-version 3.11 -o requirements/wcm-harness.txt +colorama==0.4.6 ; sys_platform == 'win32' \ + --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ + --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 + # via pytest +iniconfig==2.3.0 \ + --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \ + --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12 + # via pytest +packaging==26.3 \ + --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ + --hash=sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c + # via pytest +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 + # via pytest +pygments==2.21.0 \ + --hash=sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9 \ + --hash=sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c + # via pytest +pytest==9.1.1 \ + --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \ + --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c + # via -r requirements/wcm-harness.in diff --git a/examples/requirements/yaml-lint.in b/examples/requirements/yaml-lint.in new file mode 100644 index 00000000..249fa520 --- /dev/null +++ b/examples/requirements/yaml-lint.in @@ -0,0 +1,4 @@ +# The YAML validation step's only dependency. Compile with: +# uv pip compile requirements/yaml-lint.in --generate-hashes --universal \ +# --python-version 3.11 -o requirements/yaml-lint.txt +pyyaml diff --git a/examples/requirements/yaml-lint.txt b/examples/requirements/yaml-lint.txt new file mode 100644 index 00000000..bb951cc4 --- /dev/null +++ b/examples/requirements/yaml-lint.txt @@ -0,0 +1,77 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile requirements/yaml-lint.in --generate-hashes --universal --python-version 3.11 -o requirements/yaml-lint.txt +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 + # via -r requirements/yaml-lint.in diff --git a/examples/startup-tpm/README.md b/examples/startup-tpm/README.md new file mode 100644 index 00000000..ce150cf5 --- /dev/null +++ b/examples/startup-tpm/README.md @@ -0,0 +1,268 @@ +# startup-tpm: 15-Minute cMCP Quickstart + +Get a cMCP Runtime running with TPM-backed TRACE Trust Records in under 15 minutes. Works on any cloud VM with TPM 2.0 (Azure Trusted Launch, AWS Nitro, GCP Shielded VM) or with `CMCP_DEV_MODE=1` for local development - no hardware required for testing. + +--- + +## What you will have at the end + +- A cMCP Runtime running on port 8443, proxying calls to an upstream MCP server +- A Cedar policy that permits all tool calls (replace before production) +- A one-tool catalog (`test.echo`) and a mock MCP server that serves it +- A signed TRACE Trust Record you can inspect + +Estimated time: 15 minutes on a fresh VM, 5 minutes if Python is already installed. + +--- + +## Prerequisites + +| Requirement | Version | Notes | +|---|---|---| +| Python | 3.11+ | `python3 --version` | +| pip | any recent | `pip --version` | +| TPM 2.0 | optional | Required for hardware attestation; omit with `CMCP_DEV_MODE=1` | + +--- + +## Step 1 - Install + +```bash +pip install cmcp-runtime +``` + +Verify: + +```bash +cmcp --version +``` + +--- + +## Step 2 - Get the quickstart files + +```bash +git clone https://github.com/agentrust-io/integrations.git +cd integrations/examples/startup-tpm +``` + +The directory contains: + +``` +startup-tpm/ + cmcp-config.yaml runtime configuration + catalog.json one-tool catalog (test.echo) + policy/ + manifest.json policy bundle metadata + allow.cedar permit-all policy + schema.cedarschema Cedar schema (minimal) + server/ + mock_mcp_server.py mock upstream MCP server (stdlib only) + agent/ + echo_agent.py minimal agent script +``` + +--- + +## Step 3 - Start the mock MCP server + +The runtime proxies tool calls to the upstream MCP server listed in `catalog.json` (`http://localhost:8080/mcp`). The quickstart ships a mock: + +**Terminal 1:** + +```bash +python server/mock_mcp_server.py +``` + +To protect a real MCP server instead, point `server.url` in `catalog.json` at it and recompute nothing - the catalog hash is only pinned in production mode. + +--- + +## Step 4 - Start the runtime + +Run from inside `startup-tpm/` - the `./policy` and `./catalog.json` paths in the config resolve relative to the working directory. + +**Terminal 2, with hardware TPM (Azure Trusted Launch, AWS Nitro, GCP Shielded VM):** + +```bash +cmcp start --config cmcp-config.yaml +``` + +**Without hardware TPM (local dev, CI):** + +```bash +CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml +``` + +On Windows PowerShell: + +```powershell +$env:CMCP_DEV_MODE = "1" +cmcp start --config cmcp-config.yaml +``` + +`CMCP_DEV_MODE=1` marks the attestation `software-only-dev-mode` in the TRACE record. The runtime is fully functional but the attestation is not hardware-backed. + +Expected startup output ends with: + +``` +cMCP Runtime starting: TEE: software-only, listen: 127.0.0.1:8443 +INFO: Uvicorn running on http://127.0.0.1:8443 +``` + +--- + +## Step 5 - Make a test tool call + +**Terminal 3, Option A - agent script (recommended):** + +```bash +python agent/echo_agent.py +``` + +Expected output: + +``` +[1/1] Calling test.echo ... + -> decision: allow + -> echoed: hello from cMCP + +Closing session and fetching the signed TRACE Trust Record ... + +=== TRACE Trust Record (signed RuntimeClaim) === +{ + "cmcp_version": "1.0", + "trace": { ... }, + "gateway": { ... }, + "signature": "..." +} +``` + +**Option B - curl:** + +```bash +curl -X POST http://localhost:8443/mcp \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"test.echo","arguments":{"message":"hello"}}}' +``` + +The response carries the echoed text plus `_cmcp` enforcement metadata: + +```json +{ + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [{"type": "text", "text": "hello"}], + "_cmcp": { + "call_id": "...", + "audit_entry_hash": "...", + "would_have_denied": false, + "latency_us": 12345, + "session_id": "..." + } + } +} +``` + +--- + +## Step 6 - Get the TRACE Trust Record + +TRACE Trust Records are sealed when a session closes. Close the session (use the `session_id` from `_cmcp`): + +```bash +curl -X POST http://localhost:8443/sessions//close | python3 -m json.tool +``` + +The response is the signed `RuntimeClaim` (see `trace-output/example-trust-record.json` for a real captured example). The runtime immediately starts a fresh session, so further tool calls keep working. + +You can also export the hash-chained audit log for a closed session: + +```bash +curl "http://localhost:8443/audit/export?session_id=" > bundle.json +``` + +--- + +## Step 7 - Verify the record (and try to tamper with it) + +Save the claim from Step 6 to `claim.json`, then verify it: + +```bash +cmcp verify claim.json --audit-bundle bundle.json +``` + +``` +[cmcp verify] schema PASS +[cmcp verify] signature PASS +[cmcp verify] attestation_freshness PASS +[cmcp verify] audit_chain PASS +[cmcp verify] audit_bundle PASS (3 entries) +[cmcp verify] RESULT: PASS (verified) +``` + +Now change anything - a counter, a tool name, one character - and verify again: + +```bash +python3 -c " +import json +c = json.load(open('claim.json')) +c['gateway']['call_summary']['tool_calls_total'] += 1 +json.dump(c, open('claim.json', 'w')) +" +cmcp verify claim.json +``` + +``` +[cmcp verify] signature FAIL +[cmcp verify] RESULT: FAIL (partially_verified) +``` + +The signature covers the whole canonical claim, so no field can be altered after the fact. The same holds for the audit bundle: editing any entry breaks both its hash chain and the bundle signature. This is the tamper-evidence guarantee in one command. + +In production, pin the approved hashes so a substituted policy or catalog also fails: + +```bash +cmcp verify claim.json --policy-hash sha256: --catalog-hash sha256: +``` + +--- + +## Next steps + +| Goal | Where to look | +|---|---| +| Cedar escalation rules with HITL advice | `financial-services/`, `healthcare/` | +| Per-tenant policy isolation | `multi-tenant-saas/` | +| Agent-to-agent delegation (attenuated authority) | `ca2a-delegation/` | +| Hardware attestation on Azure | [Azure Trusted Launch docs](https://learn.microsoft.com/azure/virtual-machines/trusted-launch) | +| Writing your own Cedar policies | [Cedar policy language reference](https://docs.cedarpolicy.com/) | +| Protecting a real MCP server | Edit `catalog.json` to point `server.url` at your MCP server | +| Production enforcement | Unset `CMCP_DEV_MODE`; set `CMCP_BEARER_TOKEN`, `CMCP_POLICY_HASH`, `CMCP_CATALOG_HASH` | + +--- + +## Troubleshooting + +**Port 8443 already in use** + +```bash +# Change listen_addr in cmcp-config.yaml, e.g.: +listen_addr: 0.0.0.0:9443 +``` + +**`cmcp` command not found** + +```bash +pip show cmcp-runtime | grep Location +# make sure pip's bin/Scripts directory is on PATH +``` + +**Runtime exits immediately** + +`policy_bundle_path` and `catalog_path` in `cmcp-config.yaml` are resolved relative to the **working directory** you run `cmcp start` from, not the config file location. Run from inside `startup-tpm/`. + +**Tool call returns 502 `UPSTREAM_UNAVAILABLE`** + +The mock MCP server is not running on port 8080. Start it (Step 3). diff --git a/examples/startup-tpm/agent/echo_agent.py b/examples/startup-tpm/agent/echo_agent.py new file mode 100644 index 00000000..7ff9e061 --- /dev/null +++ b/examples/startup-tpm/agent/echo_agent.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Minimal echo agent for the cMCP startup quickstart. + +Calls test.echo through the runtime, closes the session, and prints the +signed TRACE Trust Record. + +Usage: + python echo_agent.py [--gateway http://localhost:8443] +""" + +import argparse +import json +import sys +import httpx + +DEFAULT_GATEWAY = "http://localhost:8443" + + +def run(gateway: str) -> None: + print(f"Connecting to cMCP Runtime at {gateway}") + print() + + with httpx.Client(headers={"Content-Type": "application/json"}) as client: + print("[1/1] Calling test.echo ...") + payload = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "test.echo", "arguments": {"message": "hello from cMCP"}}, + } + resp = client.post(f"{gateway}/mcp", json=payload, timeout=30) + body = resp.json() + if "error" in body: + print(f" -> error: {body['error']}") + print(" Is the runtime running? Start it with:") + print(" CMCP_DEV_MODE=1 cmcp start --config cmcp-config.yaml") + sys.exit(1) + result = body["result"] + meta = result.get("_cmcp", {}) + content = result.get("content", []) + echoed = content[0].get("text", "") if content else "" + print(" -> decision: allow") + print(f" -> echoed: {echoed}") + session_id = meta.get("session_id") + print() + + if not session_id: + print("No session id in response - cannot fetch TRACE Trust Record.") + sys.exit(1) + + print(f"Closing session {session_id} and fetching the signed TRACE Trust Record ...") + resp = client.post(f"{gateway}/sessions/{session_id}/close", timeout=10) + resp.raise_for_status() + print() + print("=== TRACE Trust Record (signed RuntimeClaim) ===") + print(json.dumps(resp.json(), indent=2)) + + print() + print("Done.") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="cMCP echo agent quickstart") + parser.add_argument("--gateway", default=DEFAULT_GATEWAY, + help=f"cMCP Runtime base URL (default: {DEFAULT_GATEWAY})") + args = parser.parse_args() + run(args.gateway) diff --git a/examples/startup-tpm/catalog.json b/examples/startup-tpm/catalog.json new file mode 100644 index 00000000..5451cac5 --- /dev/null +++ b/examples/startup-tpm/catalog.json @@ -0,0 +1,27 @@ +[ + { + "tool_name": "test.echo", + "server": { + "display_name": "Local Test Server", + "url": "http://localhost:8080/mcp", + "tls_fingerprint": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "transport": "http-sse" + }, + "approved_definition": { + "description": "Echo a message back for testing", + "input_schema": { + "type": "object", + "properties": { + "message": {"type": "string"} + }, + "required": ["message"] + } + }, + "definition_hash": "sha256:d53cc01e40ecd15dabff6e2e0562f824dcb1eda885f722d96e1583f4c2ea8273", + "compliance_domain": "internal", + "requires_baa": false, + "sensitivity_level": "public", + "added_at": "2026-06-01T00:00:00Z", + "approved_by": "developer@example.com" + } +] diff --git a/examples/startup-tpm/cmcp-config.yaml b/examples/startup-tpm/cmcp-config.yaml new file mode 100644 index 00000000..f2426a2b --- /dev/null +++ b/examples/startup-tpm/cmcp-config.yaml @@ -0,0 +1,6 @@ +policy_bundle_path: ./policy +catalog_path: ./catalog.json +listen_addr: 127.0.0.1:8443 # tokenless dev mode binds to loopback; set CMCP_BEARER_TOKEN for non-loopback binds +attestation: + provider: auto + enforcement_mode: advisory diff --git a/examples/startup-tpm/policy/allow.cedar b/examples/startup-tpm/policy/allow.cedar new file mode 100644 index 00000000..56fe1bb0 --- /dev/null +++ b/examples/startup-tpm/policy/allow.cedar @@ -0,0 +1,16 @@ +// Quickstart policy: permit all tool calls. +// Replace with your own rules before going to production. + +// Rule 1: Permit any principal to call any tool. +permit ( + principal, + action == Action::"tool_call", + resource +); + +// Rule 2: Catch-all permit for any other action type. +permit ( + principal, + action, + resource +); diff --git a/examples/startup-tpm/policy/manifest.json b/examples/startup-tpm/policy/manifest.json new file mode 100644 index 00000000..55cdc5f9 --- /dev/null +++ b/examples/startup-tpm/policy/manifest.json @@ -0,0 +1,6 @@ +{ + "version": "quickstart-v1.0", + "authored_at": "2026-06-01T00:00:00Z", + "author_identity": "developer@example.com", + "commit_sha": "0000000000000000000000000000000000000000" +} diff --git a/examples/startup-tpm/policy/schema.cedarschema b/examples/startup-tpm/policy/schema.cedarschema new file mode 100644 index 00000000..889ad3f5 --- /dev/null +++ b/examples/startup-tpm/policy/schema.cedarschema @@ -0,0 +1 @@ +{"cMCP": {}} diff --git a/examples/startup-tpm/server/mock_mcp_server.py b/examples/startup-tpm/server/mock_mcp_server.py new file mode 100644 index 00000000..e99fb5d8 --- /dev/null +++ b/examples/startup-tpm/server/mock_mcp_server.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +""" +Mock test MCP server for the startup-tpm quickstart. + +Serves test.echo on port 8080. Stdlib only -- no dependencies. + +Usage: + python startup-tpm/server/mock_mcp_server.py +""" + +import json +from http.server import BaseHTTPRequestHandler, HTTPServer + +PORT = 8080 +ECHO_TOOL = { + "name": "test.echo", + "description": "Echo a message back for testing", + "inputSchema": { + "type": "object", + "properties": {"message": {"type": "string"}}, + "required": ["message"], + }, +} + + +class Handler(BaseHTTPRequestHandler): + def do_POST(self): # noqa: N802 + if self.path != "/mcp": + self._reply(404, {"error": "not found"}) + return + length = int(self.headers.get("Content-Length", 0)) + request = json.loads(self.rfile.read(length)) + method = request.get("method", "") + if method == "tools/list": + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "result": {"tools": [ECHO_TOOL]}, + }) + return + if method != "tools/call": + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "error": {"code": -32601, "message": f"unknown method: {method}"}, + }) + return + params = request.get("params", {}) + tool = params.get("name", "") + if tool != "test.echo": + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "error": {"code": -32601, "message": f"unknown tool: {tool}"}, + }) + return + message = params.get("arguments", {}).get("message", "") + self._reply(200, { + "jsonrpc": "2.0", + "id": request.get("id"), + "result": {"content": [{"type": "text", "text": message}]}, + }) + + def _reply(self, status: int, body: dict) -> None: + payload = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, fmt, *args): + print(f"[mock-echo] {fmt % args}") + + +if __name__ == "__main__": + print(f"Mock test MCP server listening on :{PORT} (tools: test.echo)") + HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/examples/startup-tpm/trace-output/example-trust-record.json b/examples/startup-tpm/trace-output/example-trust-record.json new file mode 100644 index 00000000..9ed0d762 --- /dev/null +++ b/examples/startup-tpm/trace-output/example-trust-record.json @@ -0,0 +1,75 @@ +{ + "cmcp_version": "1.0", + "trace": { + "eat_profile": "tag:agentrust.io,2026:trace-v0.1", + "iat": 1781138542, + "subject": "spiffe://cmcp.gateway/session/db17ac23-c8df-4848-a2e9-21f6d07e7e05", + "runtime": { + "platform": "tpm2", + "measurement": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "firmware_version": "software-only-dev-mode" + }, + "policy": { + "bundle_hash": "sha256:93d237c9a83482c36908a5dcb634f3641e8c1d252444a0c6b916e480d7f45dde", + "enforcement_mode": "advisory", + "version": "quickstart-v1.0" + }, + "data_class": "public", + "tool_transcript": { + "hash": "sha256:775dcf69fe626a3921f0a8686b829cfefc3f1a12c4c64be7db44fc1f6271ace2", + "call_count": 1 + }, + "cnf": { + "jwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "ToB3lvrNHGjbh8ZPnK0Ogh0zTxLNURCt1rjk5L_M18Q", + "kid": "cmcp-4e807796" + } + } + }, + "gateway": { + "session_id": "db17ac23-c8df-4848-a2e9-21f6d07e7e05", + "gateway_version": "unknown", + "sequence_number": 2, + "prev_claim_hash": "sha256:6c13e26d34ad2b9cfbab7378b5a8795c4d4a2c8ad151c9b95023bcdcaf802bd1", + "audit_chain": { + "root": "8fa6b608999d3e2b39ae09e549bc2550e7e81e9a8fd6f4ea6589f97bbfd21389", + "tip": "775dcf69fe626a3921f0a8686b829cfefc3f1a12c4c64be7db44fc1f6271ace2", + "length": 3 + }, + "call_summary": { + "tool_calls_total": 1, + "tool_calls_allowed": 1, + "tool_calls_denied": 0, + "tool_calls_faulted": 0, + "tools_invoked": [ + "test.echo" + ], + "session_max_sensitivity": "public", + "call_graph_summary": { + "compliance_domains_touched": [ + "internal" + ], + "cross_boundary_events": [], + "edges_represent": "Edges represent temporal adjacency (call order), not data provenance. A -> B means B was called immediately after A within this session." + } + }, + "catalog": { + "hash": "sha256:4aa10f37feea8302a6d93d3183daeb7f8ba7457c3c9eb11a36e538aa026073bf", + "drift_detected": false + }, + "attestation_generated_at": "2026-06-11T00:41:41.751625+00:00", + "attestation_validity_seconds": 86400, + "attestation_stale": false, + "catalog_exceptions": [], + "call_log_summary": { + "total_calls": 1, + "tools_called": [ + "test.echo" + ], + "suspicious_sequences_detected": 0 + } + }, + "signature": "dt9Afvq_MVpJp-XZ_awZ1eqfH3Zm-vCRIQBIMlhSPtaww6V-fvi4X7C5DHtrkQPwiSLTsawZJtNy3Rw47EuuDQ" +} diff --git a/examples/weight-custody-manifest/README.md b/examples/weight-custody-manifest/README.md new file mode 100644 index 00000000..339c1c19 --- /dev/null +++ b/examples/weight-custody-manifest/README.md @@ -0,0 +1,126 @@ +# weight-custody-manifest: Custody for Model Weights + +The other examples in this repo govern what an agent may **do**. This one governs the model **weights** themselves: when a builder deploys a model into a customer's own or sovereign infrastructure, how do you prove the weights running in the enclave are exactly the ones the builder shipped, release the decryption key only against a valid hardware attestation, wipe it when custody lapses, and carry the lineage of every derivative? + +That is the [Weight Custody Manifest](https://pypi.org/project/weight-custody-manifest/) (WCM): an open protocol and reference SDK. These demos run its real code, roughly one per feature. + +For a public open-weight model, base-weight confidentiality is theater (anyone can download the weights), so the same flow does the work that still matters: **integrity and provenance** (is this the model the builder shipped, or a tampered copy), **license as a release condition**, **derivative custody and lineage**, and a **kill switch**. + +> **Honest scope.** WCM does not claim silicon-enforced custody against an operator who physically owns the hardware. Cheap published memory-bus attacks (TEE.fail, BadRAM) extract keys from live confidential-computing memory, so against that adversary WCM is accountability-grade (cost, detection, containment, mandatory physical hardening), not cryptographic custody. It is custody-grade against software and remote adversaries. The demos below use a software (mock) attestation provider so they run with no hardware; the hardware-rooted step is validated separately on real SEV-SNP and TDX silicon. + +--- + +## Setup + +```bash +git clone https://github.com/agentrust-io/integrations.git +cd integrations/examples/weight-custody-manifest +pip install -r requirements.txt # weight-custody-manifest>=0.21.0, Python 3.11+ +``` + +The offline demos need nothing else. `real_open_model.py` needs run-local extras (below). + +--- + +## Start here: the 30-second demo + +`refuse_and_wipe.py` is the whole idea in two moments: the weight-decryption key **refuses** to release into an enclave running a tampered serving stack, and **wipe-on-lapse** zeroizes it the moment custody lapses (gone, not suspended). + +```bash +python refuse_and_wipe.py +``` + +The demos below go deeper on the same machinery. + +--- + +## Gate your model load (drop-in) + +`load_guard.py` is the smallest useful integration: verify the checkpoint before your loader ever touches it. It checks the manifest's joint signature and that the bytes on disk hash to exactly what the builder bound, then refuses a tampered or swapped fork **before** the load. Drop `guarded_load()` in front of your existing `safetensors` or `transformers` load. + +```bash +python load_guard.py # offline: certified loads, tampered refused +python load_guard.py --load --model model.safetensors # gate + actually load a real file (run-local) +``` + +--- + +## The demos + +Each script is one runnable feature with mock (software) attestation, so they run anywhere. `refuse_and_wipe.py` above is the 30-second version. + +### Closed-weight vs open-weight + +The same machinery, two trust settings. **Closed** weights (a frontier model deployed into someone else's infra) need secrecy. **Open** weights (Llama, Mistral, SmolLM) are already public, so the flow instead does integrity, license, and derivative custody. + +- **`closed_model_e2e.py`** -- the closed/frontier flow where secrecy is the job (`base_confidentiality: confidential`): sign, attestation-gated release of the real decryption key, wipe-on-lapse custody, and the honest hostile-owner caveat. +- **`open_model_e2e.py`** -- the full six-step flow on an open model, honest about which steps are real work versus theater for a public base (the base's secrecy is theater; integrity, license, derivative custody, and the kill switch are the point). + +### The four layers, feature by feature + +- **`multi_stage_byom.py`** -- Layer 4 at depth (SPEC 3.8): a base to derivative to derivative chain, monotone rights (a derivative narrows but never widens), release gated on every upstream being logged, and a revocation cascading down the chain. +- **`revocation_kill_switch.py`** -- the kill switch: a serving session, a revocation, and the enclave stopping at the next cadence lapse (the key is zeroized, not suspended). +- **`channel_binding.py`** -- Layer 2 relay defense (CVE-2026-33697): the released key is sealed to the enclave's attested transport key, so a relayed attestation gets only ciphertext it cannot open. +- **`sovereign_self_custody.py`** -- 2-of-3 threshold release (SPEC 3.5): no single party, and no single forged quote, can assemble the key. + +### Transparency, post-quantum, provenance + +- **`transparency_log.py`** -- the append-only Merkle log (SPEC 3.7 / RFC 9162): inclusion and consistency proofs, and a monitor detecting a suppressed revocation. +- **`post_quantum.py`** -- sign and verify a manifest with ML-DSA-65 and with the Ed25519+ML-DSA-65 hybrid profile. +- **`provenance_model_signing.py`** -- interop with OpenSSF model-signing: a WCM manifest references a model-signing signature as its provenance, and `verify_provenance` cryptographically verifies it. Needs the extra: `pip install "weight-custody-manifest[model-signing]"`. + +### Verifying real attestations + +- **`snp_replay.py`** -- replay a recorded AMD SEV-SNP quote offline: parse the report, verify the VCEK to ASK to ARK chain to a trusted AMD root, the report signature, and the nonce binding. A synthetic bundle is committed in `fixtures/`; pass a captured bundle to replay genuine silicon. +- **`quote_verification.py`** -- the verifier machinery against a synthetic PKI: cert chain, report signature, and nonce binding, passing and failing (untrusted root, tampered report, nonce mismatch). This is the same machinery the SEV-SNP, TDX, and NVIDIA GPU verifiers plug into. + +### Over real weights (run-local) + +- **`real_open_model.py`** -- the flow over a real open model's actual bytes: it resolves SmolLM2-135M to an immutable Hugging Face commit, downloads the complete snapshot, hashes a deterministic inventory of its weights/config/tokenizer artifacts into the manifest, includes a tamper demo, and with `--infer` loads only that verified local snapshot with network fallback disabled. Network and heavy, so it is run-local and excluded from CI. +- **`real_lora_custody.py`** -- trains a genuine PEFT LoRA derivative from the pinned local SmolLM2 snapshot, hashes and deterministically packages the complete adapter inventory, encrypts it with AES-256-GCM, refuses ciphertext or bound-digest substitution, and loads only the authenticated, locally decrypted adapter. Install `requirements-lora.txt`; the model download/training run is intentionally excluded from CI while the custody mechanics have offline tests. + +```bash +python closed_model_e2e.py # any of the offline examples +pip install "weight-custody-manifest[model-signing]" # for provenance_model_signing.py +pip install -r requirements-infer.txt # for real_open_model.py +python real_open_model.py +``` + +--- + +## What runs in CI + +`run_demos.py` runs every offline example and the unit tests, and must exit 0. + +```bash +python run_demos.py # run everything +python run_demos.py --list # show the plan, including what is skipped and why +``` + +Demos are **discovered, not listed**. Every top-level module that is not a test +gets run, so a new demo is covered the day it lands. A demo that genuinely +cannot run offline goes in `REQUIRES_NETWORK` with a reason, which keeps the +exclusion visible on every run rather than buried in a workflow file. Two are +excluded today: `real_open_model.py` downloads a model, and `real_lora_custody.py` +trains a LoRA adapter. + +The same script is called from two places, and that is the point. This +repository runs it against the published PyPI package, which catches a demo that +somebody broke. The SDK repository runs it against a wheel built from the branch +under review, which catches an SDK change that breaks the demos. + +That second one is why this exists. `weight-custody-manifest` 0.27.0 shipped two +correct security changes and both broke demos here: key release began refusing +manifests whose identity was not pinned out of band, and the memory-fingerprint +challenge began requiring a signed sweep. Six demos that passed on 0.26.0 failed +on 0.27.0, and nothing caught it, because the SDK had no way to run these and +this repository only ever tested against what was already published. + +## Reference + +- Package: [weight-custody-manifest on PyPI](https://pypi.org/project/weight-custody-manifest/) +- `manifest.example.json` in this folder is a sample signed manifest for reference. + +## License + +Apache 2.0. See [LICENSE](../LICENSE) in the repo root. diff --git a/examples/weight-custody-manifest/_mock_attestation.py b/examples/weight-custody-manifest/_mock_attestation.py new file mode 100644 index 00000000..93d71144 --- /dev/null +++ b/examples/weight-custody-manifest/_mock_attestation.py @@ -0,0 +1,32 @@ +"""Explicit waivers for demos that run on mock attestation. + +These demos use ``SoftwareProvider``: evidence with no hardware behind it and +nothing a verifier could check. From the release after 0.28.4, a manifest +requires cryptographic evidence verification unless it says otherwise, and GPU +confidential-compute mode is met only by a verified GPU report (#159). A mock +cannot meet either, so these manifests say so explicitly, the way a real +deployment on unverified evidence would have to. + +On 0.28.4 and earlier the field does not exist and a manifest carrying it is +rejected, so this is a no-op there and the demos behave exactly as before. +""" + +from __future__ import annotations + +from typing import Any + +from wcm.models import ReleasePolicy + +#: True once the SDK has the requirement these waivers answer. +REQUIRES_VERIFICATION = "require_evidence_verification" in ReleasePolicy.model_fields + + +def waive_mock_verification(doc: dict[str, Any]) -> dict[str, Any]: + """Add the two waivers to a manifest document, in place, and return it.""" + if REQUIRES_VERIFICATION: + policy = doc["release_policy"] + policy["require_evidence_verification"] = False + gpu = policy.get("required_gpu_measurement") + if gpu is not None: + gpu["require_cc_mode"] = False + return doc diff --git a/examples/weight-custody-manifest/channel_binding.py b/examples/weight-custody-manifest/channel_binding.py new file mode 100644 index 00000000..86b9a16d --- /dev/null +++ b/examples/weight-custody-manifest/channel_binding.py @@ -0,0 +1,201 @@ +"""Channel binding: sealing the released key to the attested enclave (CVE-2026-33697). + + python channel_binding.py + +Real WCM code, no hardware. Nonce binding stops a REPLAYED quote, but not a +RELAYED one: a network adversary terminating the enclave's channel could forward +a live valid quote and divert the released key onto its own channel. WCM closes +that gap (SPEC 3.2 channel binding): the enclave folds a transport public key +into the quote's REPORT_DATA under the nonce, and the KBS seals the released key +to that transport key instead of returning it in the clear. This shows both +teeth: a relay that just forwards the release gets ciphertext it cannot open, and +a relay that substitutes its own transport key fails quote verification outright. + +The attestation quote here is signed by a synthetic PKI (real cryptography, not a +real vendor chain) so the verification is genuine end to end. +""" +from __future__ import annotations + +import base64 +import hashlib +import json +from datetime import datetime, timedelta, timezone + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import NameOID + +from wcm import ( + CompositeEvidence, + CpuQuote, + Ed25519Signer, + generate_ed25519, + generate_transport_keypair, + JsonQuoteParser, + KeyBrokerService, + manifest_identity, + open_sealed, + QuoteVerifier, + SealError, + TrustStore, + WeightCustodyManifest, +) + +NOW = datetime(2026, 1, 1, tzinfo=timezone.utc) + + +def rule(title: str) -> None: + print(f"\n{'=' * 70}\n{title}\n{'=' * 70}") + + +def sha256(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def _pki(): + """A self-signed root CA and a leaf attestation-key cert it signs.""" + root_key = ec.generate_private_key(ec.SECP256R1()) + leaf_key = ec.generate_private_key(ec.SECP256R1()) + + def cert(subject, subj_key, issuer, issuer_key, ca): + b = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subject)])) + .issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, issuer)])) + .public_key(subj_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(NOW - timedelta(days=1)) + .not_valid_after(NOW + timedelta(days=365)) + ) + if ca: + b = b.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + return b.sign(issuer_key, hashes.SHA256()) + + root = cert("wcm-demo-root", root_key, "wcm-demo-root", root_key, True) + leaf = cert("attestation-key", leaf_key, "wcm-demo-root", root_key, False) + return root, leaf, leaf_key + + +def _quote(leaf, leaf_key, nonce_hex: str, transport_pub_hex: str) -> str: + """A quote whose REPORT_DATA binds sha256(nonce || transport_public_key).""" + digest = hashlib.sha256(bytes.fromhex(nonce_hex) + bytes.fromhex(transport_pub_hex)).digest() + report_body = digest + bytes(32) + b"measurement-and-tcb" + signature = leaf_key.sign(report_body, ec.ECDSA(hashes.SHA256())) + doc = { + "report_b64": base64.b64encode(report_body).decode(), + "signature_b64": base64.b64encode(signature).decode(), + "leaf_pem": leaf.public_bytes(serialization.Encoding.PEM).decode(), + "intermediates_pem": [], + "report_data_offset": 0, + } + return base64.b64encode(json.dumps(doc).encode()).decode() + + +def _manifest(): + serving = sha256(b"measured serving stack") + doc = { + "manifest_version": "0.1", + "weights_hash": sha256(b"the model weights"), + "builder": {"identity": "lab", "signing_key": "ed25519:lab"}, + "release_terms": { + "license": "deployment-agreement", + "permitted_derivatives": "none", + "permitted_environments": ["attested-enclave"], + }, + "release_policy": { + "required_assurance_tier": "hardware-attested", + "trusted_time_source": "secure-tsc", + "required_hw_platform": ["amd-sev-snp"], + "required_serving_image": { + "signer": "ed25519:lab", + "release_rule": "prefer-current", + "accepted_measurements": [{"measurement": serving, "status": "current"}], + }, + }, + "custody": { + "custodian": "customer", + "custodian_type": "customer-self-custody", + "kbs_image": {"measurement": sha256(b"kbs image"), "signer": "ed25519:lab"}, + "enclave_id": "did:example:enclave", + "attestation_cadence": "1h", + }, + } + b, c = generate_ed25519(), generate_ed25519() + m = WeightCustodyManifest.model_validate(doc) + return m.with_signatures([ + Ed25519Signer(b).sign(m.unsigned_dict(), role="builder", signer="lab"), + Ed25519Signer(c).sign(m.unsigned_dict(), role="custodian", signer="customer"), + ]), serving + + +def _evidence(manifest, serving, nonce, quote_b64, transport_pub_hex): + return CompositeEvidence( + cpu=CpuQuote( + platform="amd-sev-snp", + assurance_tier="hardware-attested", + serving_image_measurement=serving, + nonce_echo=nonce, + attestation_key_id="vcek:demo", + quote_b64=quote_b64, + transport_public_key=transport_pub_hex, + ) + ) + + +def main() -> int: + root, leaf, leaf_key = _pki() + manifest, serving = _manifest() + KEY = b"the-weight-decryption-key-32bytes" + + trust = TrustStore() + trust.add_root(root) + kbs = KeyBrokerService( + {manifest.weights_hash: KEY}, + now=lambda: NOW, + cpu_quote_verifier=QuoteVerifier(JsonQuoteParser(), trust), + require_channel_binding=True, + # weight-custody-manifest 0.27.0 refuses to release unless the manifest's + # identity is pinned out of band. Without it, a caller could present an + # attacker-authored policy that reused a weights hash the broker already + # held and be released against terms nobody agreed. + trusted_manifest_identities=[manifest_identity(manifest)], + ) + + enclave_priv, enclave_pub = generate_transport_keypair() + attacker_priv, attacker_pub = generate_transport_keypair() + + rule("Step 1 - Legit enclave: the key releases, SEALED to its transport key") + ch = kbs.issue_challenge() + quote = _quote(leaf, leaf_key, ch.nonce, enclave_pub) # bound to the enclave key + decision = kbs.verify_and_release(manifest, _evidence(manifest, serving, ch.nonce, quote, enclave_pub)) + print("released :", decision.released) + print("raw key on the wire :", decision.key, " (never; sealed only)") + opened = open_sealed(decision.sealed_key, enclave_priv) + print("enclave opens the seal:", opened == KEY) + + rule("Step 2 - A relay forwards the sealed release: it gets only ciphertext") + try: + open_sealed(decision.sealed_key, attacker_priv) + print("attacker opened it : True (should not happen)") + except SealError: + print("attacker opens the seal: FAILS (SealError) - it is not the bound enclave") + + rule("Step 3 - A relay substitutes its own transport key: quote verification fails") + ch2 = kbs.issue_challenge() + quote2 = _quote(leaf, leaf_key, ch2.nonce, enclave_pub) # still bound to the enclave key + # ...but the relay claims the attacker key so the seal would land on its channel. + relayed = kbs.verify_and_release( + manifest, _evidence(manifest, serving, ch2.nonce, quote2, attacker_pub) + ) + print("released :", relayed.released, " (denied)") + reason = next((c.detail for c in relayed.failures if c.name == "cpu_quote_verified"), None) + print("why :", reason) + print() + print("Either way the relay is defeated: forward the seal and it is unopenable;") + print("swap the transport key and REPORT_DATA no longer matches, so nothing releases.") + return 0 if (decision.released and not relayed.released) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/weight-custody-manifest/closed_model_e2e.py b/examples/weight-custody-manifest/closed_model_e2e.py new file mode 100644 index 00000000..5e2b04c0 --- /dev/null +++ b/examples/weight-custody-manifest/closed_model_e2e.py @@ -0,0 +1,148 @@ +"""End-to-end custody for a CLOSED-weight (frontier) model, where secrecy IS the job. + + python closed_model_e2e.py + +Real WCM code with a software (mock) attestation provider, so it runs anywhere +with no hardware. + +Closed vs open, the one distinction that drives everything +--------------------------------------------------------- +This is the counterpart to `open_model_e2e.py`. For a CLOSED frontier model the +base weights are a secret the builder is deploying into someone else's +infrastructure, so the whole point of attestation-gated release is secrecy: the +decryption key must reach only a builder-approved, measured serving stack, and +nothing else. (For an OPEN-weight model the base is already public, so the same +machinery instead does integrity, license, and derivative custody. See +`open_model_e2e.py`.) + +`base_confidentiality` is set to "confidential" and stated on the manifest, so a +verifier can see that this manifest really is protecting secrecy, not theater. +""" +from __future__ import annotations + +import hashlib + +from wcm import ( + Ed25519Signer, + EnclaveSession, + generate_ed25519, + KeyBrokerService, + manifest_identity, + SoftwareProvider, + VerificationContext, + verify_manifest, + WeightCustodyManifest, +) + +from _mock_attestation import waive_mock_verification + + +def rule(title: str) -> None: + print(f"\n{'=' * 70}\n{title}\n{'=' * 70}") + + +def sha256(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def build_manifest(weights_hash: str, serving: str, org: str) -> dict: + return { + "manifest_version": "0.1", + "weights_hash": weights_hash, + "builder": {"identity": org, "signing_key": "ed25519:builder"}, + "release_terms": { + "license": "Frontier-Model-Deployment-Agreement (no redistribution)", + "permitted_derivatives": "none", + "derivatives": "none", + "permitted_environments": ["customer-attested-enclave"], + }, + "release_policy": { + "required_assurance_tier": "hardware-attested", + "trusted_time_source": "secure-tsc", + "required_hw_platform": ["amd-sev-snp", "nvidia-cc-gpu"], + "required_gpu_measurement": {"rim_pin": "nvidia-rim:golden"}, + "required_serving_image": { + "signer": "ed25519:builder", + "release_rule": "prefer-current", + "accepted_measurements": [{"measurement": serving, "status": "current"}], + }, + "attestation_revocation_check": "live-per-release, max-cache-age: short-window", + "revocation_authority": "builder-and-opaque-joint", + }, + "custody": { + "custodian": "customer-platform-team", + "custodian_type": "customer-self-custody", + "kbs_image": {"measurement": sha256(b"builder-signed KBS image"), "signer": "ed25519:builder"}, + "enclave_id": "did:example:customer-enclave-01", + "attestation_cadence": "30m", + }, + # The distinction this whole demo turns on: the base weights ARE secret. + "base_confidentiality": "confidential", + "deployment_model": "builder-to-customer", + } + + +def main() -> int: + builder, custodian = generate_ed25519(), generate_ed25519() + weights_hash = sha256(b"") + serving = sha256(b"vllm-0.6.3 + builder policy bundle (the approved serving stack)") + + rule("Step 0 - Certify: the manifest binds the secret weights + serving stack") + doc = build_manifest(weights_hash, serving, "frontier-lab") + manifest = WeightCustodyManifest.model_validate(waive_mock_verification(doc)) + manifest = manifest.with_signatures([ + Ed25519Signer(builder).sign(manifest.unsigned_dict(), role="builder", signer="frontier-lab"), + Ed25519Signer(custodian).sign(manifest.unsigned_dict(), role="custodian", signer="customer-platform-team"), + ]) + print("base_confidentiality :", manifest.base_confidentiality.value, " (secrecy is the job)") + print("deployment_model :", manifest.deployment_model.value) + print("signed jointly by builder + custodian") + + rule("Step 1 - Verify the manifest (both required roles signed)") + ctx = VerificationContext() + ctx.add_key(builder.public_bytes) + ctx.add_key(custodian.public_bytes) + result = verify_manifest(manifest, ctx) + print("manifest valid :", result.ok) + for note in result.notes: + print(" note:", note) + if not result.ok: + return 1 + + rule("Step 2 - Attestation gate: the decryption key releases only into the approved enclave") + kbs = KeyBrokerService( + {weights_hash: b"the-secret-weight-decryption-key"}, + # weight-custody-manifest 0.27.0 refuses to release unless the manifest's + # identity is pinned out of band. Without it, a caller could present an + # attacker-authored policy that reused a weights hash the broker already + # held and be released against terms nobody agreed. + trusted_manifest_identities=[manifest_identity(manifest)], + ) + challenge = kbs.issue_challenge() + evidence = SoftwareProvider().produce( + challenge, serving_image_measurement=serving, gpu_measurement="nvidia-rim:golden" + ) + decision = kbs.verify_and_release(manifest, evidence) + print("key released :", decision.released) + print("the key decrypts the secret weights ONLY under the builder-signed, measured") + print("serving stack. A different or tampered stack gets nothing (see refuse_and_wipe.py).") + + rule("Step 3 - Wipe-on-lapse custody (bounded exposure of the secret)") + session = EnclaveSession.from_release(manifest, decision) + session.use_key() + print("serving under a", doc["custody"]["attestation_cadence"], "cadence, time_floor =", session.time_floor.value) + print("miss the re-attestation and the key is zeroized, so worst-case exposure of") + print("the secret is one cadence window even if a revocation signal is blocked.") + + rule("The honest ceiling for a secret") + print("secrecy holds against software and remote adversaries. It does NOT hold against") + print("an operator who physically owns the hardware: TEE.fail / BadRAM extract keys") + print("from live memory (open question 8.8). Against that party WCM is accountability-") + print("grade (cost, detection, containment, mandatory physical hardening), not") + print("cryptographic custody. A frontier builder self-selects its counterparties on") + print("exactly this line.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/weight-custody-manifest/fixtures/snp_quote_synthetic.json b/examples/weight-custody-manifest/fixtures/snp_quote_synthetic.json new file mode 100644 index 00000000..0b9b00e0 --- /dev/null +++ b/examples/weight-custody-manifest/fixtures/snp_quote_synthetic.json @@ -0,0 +1,11 @@ +{ + "kind": "wcm-snp-quote-bundle/v1", + "source": "synthetic", + "note": "Self-consistent synthetic ARK->VCEK chain and report. NOT real hardware. Replace with tools/capture_snp_quote.py output for a genuine Azure SEV-SNP quote.", + "report_b64": "AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACaLbLiPxUEzQVmBlU6wEnF5xjo+c6SM4dt8aehghr4hQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERERAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOVfr/UlF6aVEeNEtcgt1yVfTcnaevR3bh8NkO8NBC9ugKwnqgg4seL0yJuRGoDOMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAugtnFRQ0+9Ir8kMPO+NbzMBsl+B3RvfrmVwsW4klzwgY61mK2LM16sbWbMWhqExDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "vcek_pem": "-----BEGIN CERTIFICATE-----\nMIIBkjCCARigAwIBAgIUB28IzgyENEygWjv7jqffhGZpw7swCgYIKoZIzj0EAwMw\nNDEyMDAGA1UEAwwpc3ludGhldGljLXNucC1yb290IChzdGFuZC1pbiBmb3IgQU1E\nIEFSSykwIBcNMjAwMTAxMDAwMDAwWhgPMjA5OTAxMDEwMDAwMDBaMB8xHTAbBgNV\nBAMMFFNFVi1WQ0VLIChzeW50aGV0aWMpMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE\nfNprLZF3rSMcTK0kLOZfBlfX7+KVQEbq9kmJOjKxDF2T1bKC0kF0jeFT6aq574wx\nTiIqGq7MMPWAdSQKHcQ6s8mcQZBZtODpJwDF683MnNc7hnHqN/AdLq8v77WTc5gp\nMAoGCCqGSM49BAMDA2gAMGUCMQDrVxfCf2knshEQvL+vyUuTU/nk/+QHpWDP+WAk\naMNdRUrzh3SGlZIAwgtqq+seorgCMF0X9n1VutxigXAXaWkHje6BpuAoyKFWYdmN\n1gePcPp9kUJZ/nSu9RIl7NvwMueinw==\n-----END CERTIFICATE-----\n", + "intermediates_pem": [], + "root_pem": "-----BEGIN CERTIFICATE-----\nMIIBvTCCAUKgAwIBAgIUIB3+SQg0A8tekhc1NbDQ9LSCE8IwCgYIKoZIzj0EAwMw\nNDEyMDAGA1UEAwwpc3ludGhldGljLXNucC1yb290IChzdGFuZC1pbiBmb3IgQU1E\nIEFSSykwIBcNMjAwMTAxMDAwMDAwWhgPMjA5OTAxMDEwMDAwMDBaMDQxMjAwBgNV\nBAMMKXN5bnRoZXRpYy1zbnAtcm9vdCAoc3RhbmQtaW4gZm9yIEFNRCBBUkspMHYw\nEAYHKoZIzj0CAQYFK4EEACIDYgAEuUSltY1Uj1sQzYTsecVdrNq9x0sgogawXEOl\nG/hcShU4GgPfFxUhryvWyeujHsaaz05LXPXVf/wJujBIrSxrAfxHdkTlfjuLzP5P\nEimoco+iYXuBjgo+Sz7nJaL36ZBaoxMwETAPBgNVHRMBAf8EBTADAQH/MAoGCCqG\nSM49BAMDA2kAMGYCMQDmJrgizN8uN1kQbxV8qW+1yac3XBpoDxwzMVYWD/U+yHbY\npAdF3OgGvlH5MWxTcM0CMQC4spaNY5CzLmMctCIyHz8Js9wbDpfGTAdbYB1rTwUr\ndF/mAzWU2hWC53hx5/T8oxw=\n-----END CERTIFICATE-----\n", + "expected_nonce": "abababababababababababababababababababababababababababababababab", + "expected_measurement": "111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111" +} diff --git a/examples/weight-custody-manifest/load_guard.py b/examples/weight-custody-manifest/load_guard.py new file mode 100644 index 00000000..43428272 --- /dev/null +++ b/examples/weight-custody-manifest/load_guard.py @@ -0,0 +1,199 @@ +"""Gate your model load: verify the weights before you load them. + +A drop-in load guard. Before a checkpoint is loaded, prove two things: the +manifest that certifies it is jointly signed (builder + custodian), and the +bytes on disk hash to exactly what that manifest binds. A tampered or swapped +fork is refused BEFORE it ever reaches your loader. + +This is the integrity and provenance gate (Layer 1): "is this the checkpoint the +builder shipped." Honest scope: it catches tampering and swaps against software +and remote adversaries; it is accountability-grade, not silicon-proof, against an +operator who physically owns the hardware. + + pip install weight-custody-manifest + python load_guard.py # offline demo: certified loads, tampered refused + python load_guard.py --load --model model.safetensors # gate + actually load a real file +""" +from __future__ import annotations + +import argparse +import hashlib +import pathlib +import tempfile + +from wcm import ( + Ed25519Signer, + VerificationContext, + WeightCustodyManifest, + generate_ed25519, + verify_manifest, +) + + +def sha256_bytes(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def sha256_file(path: pathlib.Path) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(1 << 20), b""): + h.update(chunk) + return "sha256:" + h.hexdigest() + + +def banner(text: str) -> None: + print(f"\n{'=' * 64}\n{text}\n{'=' * 64}") + + +def build_manifest(weights_hash: str, org: str) -> dict: + serving = sha256_bytes(b"vllm + policy-bundle (the builder-signed serving stack)") + return { + "manifest_version": "0.1", + "weights_hash": weights_hash, + "builder": {"identity": org, "signing_key": "ed25519:demo"}, + "release_terms": { + "license": "Frontier-Model-License", + "permitted_derivatives": "fine-tune-only", + "derivatives": "fine-tune-only", + "permitted_environments": ["enterprise-governed-enclave"], + }, + "release_policy": { + "required_assurance_tier": "hardware-attested", + "trusted_time_source": "secure-tsc", + "required_hw_platform": ["amd-sev-snp", "nvidia-cc-gpu"], + "required_gpu_measurement": {"rim_pin": "nvidia-rim:golden"}, + "required_serving_image": { + "signer": "ed25519:demo", + "release_rule": "prefer-current", + "accepted_measurements": [{"measurement": serving, "status": "current"}], + }, + "attestation_revocation_check": "live-per-release, max-cache-age: short-window", + "revocation_authority": "builder-and-opaque-joint", + }, + "custody": { + "custodian": org, + "custodian_type": "customer-self-custody", + "kbs_image": {"measurement": sha256_bytes(b"reference-kbs-image"), "signer": "ed25519:demo"}, + "enclave_id": "did:example:enclave-01", + "attestation_cadence": "1h", + }, + "base_confidentiality": "gated-open", + "deployment_model": "builder-to-customer", + } + + +class RefusedToLoad(Exception): + """The checkpoint failed verification and must not be loaded.""" + + +def guarded_load(model_path, manifest, ctx, *, do_load: bool = False) -> str: + """Verify the manifest and the bytes on disk BEFORE loading; refuse on any mismatch. + + The whole point: both checks run before a single byte reaches your loader. + Returns the verified digest, or raises RefusedToLoad. + """ + model_path = pathlib.Path(model_path) + + # 1. The manifest itself must be jointly signed and valid. + if not verify_manifest(manifest, ctx).ok: + raise RefusedToLoad("manifest signature invalid (not the certified manifest)") + + # 2. The bytes on disk must hash to exactly what the manifest binds. + digest = sha256_file(model_path) + if digest != manifest.weights_hash: + raise RefusedToLoad( + "weights hash mismatch\n" + f" manifest binds : {manifest.weights_hash}\n" + f" file on disk : {digest}" + ) + + # Only now is it safe to load. + if do_load: + _real_load(model_path) + return digest + + +def _real_load(model_path: pathlib.Path) -> None: + """Actually load the verified file (run-local; needs the infer extras).""" + from safetensors import safe_open # lazy: pip install -r requirements-infer.txt + + with safe_open(str(model_path), framework="pt") as f: + keys = list(f.keys()) + print(f"loaded {len(keys)} tensors from {model_path.name} (verified first)") + + +def _signed_manifest(weights_hash: str, org: str = "frontier-labs"): + """Return (manifest, verification_context) for a jointly-signed manifest.""" + builder, custodian = generate_ed25519(), generate_ed25519() + manifest = WeightCustodyManifest.model_validate(build_manifest(weights_hash, org)) + manifest = manifest.with_signatures([ + Ed25519Signer(builder).sign(manifest.unsigned_dict(), role="builder", signer=org), + Ed25519Signer(custodian).sign(manifest.unsigned_dict(), role="custodian", signer=org), + ]) + ctx = VerificationContext() + ctx.add_key(builder.public_bytes) + ctx.add_key(custodian.public_bytes) + return manifest, ctx + + +def demo() -> None: + with tempfile.TemporaryDirectory() as tmp: + tmp = pathlib.Path(tmp) + weights = b"" + certified = tmp / "model.certified.bin" + certified.write_bytes(weights) + + # The builder signs a manifest binding this exact checkpoint. + manifest, ctx = _signed_manifest(sha256_bytes(weights)) + + banner("1. The certified checkpoint. Verified, then loaded.") + digest = guarded_load(certified, manifest, ctx) + print("manifest signature :", "valid (builder + custodian)") + print("weights hash :", digest[:23], "... matches the manifest") + print("-> load proceeds") + + banner("2. A tampered fork. Refused before it loads.") + b = bytearray(weights) + b[0] ^= 0x01 # flip a single byte + tampered = tmp / "model.tampered.bin" + tampered.write_bytes(bytes(b)) + try: + guarded_load(tampered, manifest, ctx) + print("-> load proceeds") # not reached + except RefusedToLoad as exc: + print("REFUSED:", exc) + print("-> the loader never sees the bytes. One flipped byte is enough.") + + banner("Verify the weights before you load them.") + print("Integrity gate only (Layer 1). Honest scope: this catches tampering and swaps") + print("against software and remote adversaries. It is accountability-grade, not") + print("silicon-proof, against an operator who physically owns the hardware.") + + +def main() -> None: + ap = argparse.ArgumentParser( + description="Gate a model load on a signed Weight Custody Manifest." + ) + ap.add_argument("--model", help="path to a real checkpoint to gate (run-local)") + ap.add_argument( + "--load", action="store_true", + help="actually load the file after verifying (needs --model and the infer extras)", + ) + args = ap.parse_args() + + if not args.model: + demo() + return + + # Real run: a builder signs a manifest over this file, then the guard runs + # before the load. Swap in a manifest you received from the builder instead. + path = pathlib.Path(args.model) + manifest, ctx = _signed_manifest(sha256_file(path)) + banner(f"Gating {path.name}") + guarded_load(path, manifest, ctx, do_load=args.load) + print("verified" + (" and loaded" if args.load else " (pass --load to load it)")) + + +if __name__ == "__main__": + main() diff --git a/examples/weight-custody-manifest/manifest.example.json b/examples/weight-custody-manifest/manifest.example.json new file mode 100644 index 00000000..75811db4 --- /dev/null +++ b/examples/weight-custody-manifest/manifest.example.json @@ -0,0 +1,71 @@ +{ + "manifest_version": "0.1", + "weights_hash": "sha256:4a1c9b024a1c9b024a1c9b024a1c9b024a1c9b024a1c9b024a1c9b024a1c9b02", + "builder": { + "identity": "example-builder", + "signing_key": "ed25519:builder-key-placeholder" + }, + "release_terms": { + "license": "customer-deployment-agreement-ref:CDA-2026-0091", + "permitted_derivatives": "fine-tune-only, no re-export of base weights", + "permitted_environments": ["opaque-cmcp-attested-enclave"], + "jurisdiction_restriction": "US, EU" + }, + "release_policy": { + "required_assurance_tier": "hardware-attested", + "physical_hardening": "not-required", + "trusted_time_source": "secure-tsc", + "memory_fingerprint_challenge": "not-required", + "required_hw_platform": ["amd-sev-snp", "nvidia-cc-gpu"], + "required_gpu_measurement": { + "rim_pin": "nvidia-rim:driver+vbios golden measurement id", + "note": "GPU attestation report is a separate evidence chain from the CVM report; verify both plus the serving-image measurement, bound by a nonce echo (SPEC 3.2)" + }, + "tenancy": "shared", + "required_serving_image": { + "signer": "ed25519:builder-key-placeholder", + "release_rule": "prefer-current: refuse a retiring image when a current one is available; require signed/attested time for retire_after, never the host clock", + "accepted_measurements": [ + { + "measurement": "sha256:5e2d5e2d5e2d5e2d5e2d5e2d5e2d5e2d5e2d5e2d5e2d5e2d5e2d5e2d5e2d5e2d", + "status": "current" + }, + { + "measurement": "sha256:6f3e6f3e6f3e6f3e6f3e6f3e6f3e6f3e6f3e6f3e6f3e6f3e6f3e6f3e6f3e6f3e", + "status": "retiring", + "retire_after": "2026-07-16T00:00:00Z" + }, + { + "measurement": "sha256:70af70af70af70af70af70af70af70af70af70af70af70af70af70af70af70af", + "status": "revoked" + } + ], + "note": "key decrypts only under a builder-signed serving stack whose measurement is current or retiring; status:revoked hard-fails immediately" + }, + "key_release_mode": "attestation-gated", + "replay_protection": "kbs-nonce-required", + "attestation_revocation_check": "live-per-release, max-cache-age: short-window", + "revocation_authority": "builder-and-opaque-joint", + "sovereign_profile": { + "enabled": false, + "revocation_authority": "quorum", + "sovereign_signer": null, + "note": "when enabled, no single party (builder included) can revoke or dark the model unilaterally" + } + }, + "custody": { + "custodian": "opaque-systems", + "custodian_type": "opaque-hosted", + "kbs_image": { + "measurement": "sha256:abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", + "signer": "ed25519:opaque-key-placeholder", + "note": "the key release service runs in an attested enclave in every profile, Opaque-hosted included" + }, + "enclave_id": "did:opaque:example-enclave-04", + "attestation_cadence": "24h", + "kbs_attestation_cadence": "24h" + }, + "base_confidentiality": "confidential", + "deployment_model": "builder-to-customer", + "signatures": [] +} diff --git a/examples/weight-custody-manifest/multi_stage_byom.py b/examples/weight-custody-manifest/multi_stage_byom.py new file mode 100644 index 00000000..97a3d279 --- /dev/null +++ b/examples/weight-custody-manifest/multi_stage_byom.py @@ -0,0 +1,147 @@ +"""Multi-stage BYOM: sequential re-custody down a derivative chain (SPEC 3.8). + + python multi_stage_byom.py + +Real WCM code, no hardware. A base model is fine-tuned, then that derivative is +fine-tuned again, each stage getting its own signed manifest that chains to its +parent by `derived_from`. `verify_lineage` enforces the three properties that +make a multi-stage pipeline safe: + + - monotone rights: a derivative may NARROW but never WIDEN the rights it + inherits (the `derivatives` policy and `permitted_environments`); + - upstream-logged gating: release is allowed only if every manifest up the + chain is present in the transparency log; + - revocation cascade: revoking any manifest invalidates everything downstream. +""" +from __future__ import annotations + +import hashlib + +from wcm import ( + Ed25519Signer, + WeightCustodyManifest, + generate_ed25519, + is_root, + verify_lineage, +) + + +def rule(title: str) -> None: + print(f"\n{'=' * 70}\n{title}\n{'=' * 70}") + + +def sha256(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def build_manifest( + *, + weights_hash: str, + org: str, + derivatives: str, + environments: list[str], + derived_from: str | None = None, + rights_holder: dict | None = None, +) -> dict: + serving = sha256(b"measured serving stack") + doc: dict = { + "manifest_version": "0.1", + "weights_hash": weights_hash, + "builder": {"identity": org, "signing_key": "ed25519:gov"}, + "release_terms": { + "license": "enterprise-governance", + "permitted_derivatives": derivatives, + "derivatives": derivatives, + "permitted_environments": environments, + }, + "release_policy": { + "required_assurance_tier": "hardware-attested", + "trusted_time_source": "secure-tsc", + "required_hw_platform": ["amd-sev-snp"], + "required_serving_image": { + "signer": "ed25519:gov", + "release_rule": "prefer-current", + "accepted_measurements": [{"measurement": serving, "status": "current"}], + }, + }, + "custody": { + "custodian": org, + "custodian_type": "customer-self-custody", + "kbs_image": {"measurement": sha256(b"kbs image"), "signer": "ed25519:gov"}, + "enclave_id": "did:example:gov-enclave", + "attestation_cadence": "1h", + }, + "base_confidentiality": "gated-open", + "deployment_model": "byom-symmetric", + } + if derived_from is not None: + doc["derived_from"] = derived_from + if rights_holder is not None: + doc["rights_holder"] = rights_holder + return doc + + +def sign(doc: dict, keys) -> WeightCustodyManifest: + m = WeightCustodyManifest.model_validate(doc) + return m.with_signatures([ + Ed25519Signer(keys[0]).sign(m.unsigned_dict(), role="builder", signer=doc["builder"]["identity"]), + Ed25519Signer(keys[1]).sign(m.unsigned_dict(), role="custodian", signer=doc["custody"]["custodian"]), + ]) + + +def show(label: str, result) -> None: + chain = " -> ".join(h.split(":")[1][:10] + "..." for h in result.chain) if result.chain else "(none)" + print(f"{label:<34} ok={result.ok} depth={result.depth} chain={chain}") + for err in getattr(result, "errors", []) or []: + print(" reason:", err) + + +def main() -> int: + keys = (generate_ed25519(), generate_ed25519()) + + rule("Build a 3-stage chain: base -> fine-tune -> fine-tune-of-fine-tune") + base_h = sha256(b"public base checkpoint") + d1_h = sha256(b"base + proprietary fine-tune A") + d2_h = sha256(b"derivative-1 + fine-tune B") + + # Rights narrow at every stage: unrestricted -> fine-tune-only -> none, and + # the permitted environments shrink. That is the monotone direction. + base = sign(build_manifest( + weights_hash=base_h, org="acme-gov", derivatives="unrestricted", + environments=["enclave-a", "enclave-b"]), keys) + d1 = sign(build_manifest( + weights_hash=d1_h, org="acme-gov", derivatives="fine-tune-only", + environments=["enclave-a"], derived_from=base_h, + rights_holder={"base": "meta", "derivative": "acme"}), keys) + d2 = sign(build_manifest( + weights_hash=d2_h, org="acme-gov", derivatives="none", + environments=["enclave-a"], derived_from=d1_h, + rights_holder={"base": "acme", "derivative": "acme"}), keys) + manifests = {base_h: base, d1_h: d1, d2_h: d2} + print("root is a base manifest:", is_root(base), " leaf derived_from:", d2.derived_from[:19], "...") + + rule("1. Monotone rights: the narrowing chain verifies") + show("leaf d2 (rights narrow):", verify_lineage(manifests, d2_h)) + + rule("2. A derivative that WIDENS its parent's rights is rejected") + # d1 allows only fine-tune-only; a child that re-opens to unrestricted widens. + bad_h = sha256(b"derivative that illegally re-widens") + bad = sign(build_manifest( + weights_hash=bad_h, org="acme-gov", derivatives="unrestricted", # widened! + environments=["enclave-a"], derived_from=d1_h, + rights_holder={"base": "acme", "derivative": "rogue"}), keys) + show("widened child:", verify_lineage({**manifests, bad_h: bad}, bad_h)) + + rule("3. Upstream-logged gating: release only if every ancestor is logged") + show("all three logged:", verify_lineage(manifests, d2_h, logged=[base_h, d1_h, d2_h])) + show("d1 missing from log:", verify_lineage(manifests, d2_h, logged=[base_h, d2_h])) + + rule("4. Revocation cascades down the chain") + show("revoke the base:", verify_lineage(manifests, d2_h, revoked=[base_h])) + print("revoking the base invalidates the leaf two stages down: one pull kills the") + print("whole lineage, which is what sequential re-custody has to guarantee.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/weight-custody-manifest/open_model_e2e.py b/examples/weight-custody-manifest/open_model_e2e.py new file mode 100644 index 00000000..e78c40ed --- /dev/null +++ b/examples/weight-custody-manifest/open_model_e2e.py @@ -0,0 +1,250 @@ +"""End-to-end Weight Custody Manifest flow on an OPEN-weight model. + +Run it: + + python open_model_e2e.py + +Everything here is real WCM code with a **software (mock) attestation provider**, +so it runs anywhere with no hardware. The point is to show how the whole flow +fits together, and to be honest about what each step is actually doing when the +base model is public. + +The reframe that drives this demo +--------------------------------- +For a closed frontier model the job is secrecy: don't let the weights leak. For +an OPEN-weight model (Llama, Mistral, SmolLM, ...) the base weights are already +downloadable, so encrypting them and gating decryption behind attestation +protects nothing - anyone can just download the same checkpoint. Saying that +plainly matters. What the same six-step machinery still does, and why you'd run +it anyway: + + * Integrity / provenance - prove you are running exactly the certified + checkpoint and serving stack, unmodified, not a silently tampered fork. + * License / field-of-use - make the model's license a technical release + condition, not just contract text. + * Derivative custody - the fine-tune trained on your proprietary data is real, + novel IP that never existed publicly. THIS is what you're protecting. + * A kill switch - now triggered by a safety recall or license violation, not + by "we detected theft". + +For a fully-public base, the base's own confidentiality is theater; this demo +labels it as such and puts the derivative at the center. +""" +from __future__ import annotations + +import hashlib + +from wcm import ( + Ed25519Signer, + EnclaveSession, + generate_ed25519, + is_root, + KeyBrokerService, + KeyWipedError, + manifest_identity, + SoftwareProvider, + VerificationContext, + verify_lineage, + verify_manifest, + WeightCustodyManifest, +) + +from _mock_attestation import waive_mock_verification + + +def rule(title: str) -> None: + print(f"\n{'=' * 70}\n{title}\n{'=' * 70}") + + +def sha256(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def build_manifest( + *, + weights_hash: str, + license_text: str, + serving_measurement: str, + builder_id: str, + custodian_id: str, + derivatives: str, + derived_from: str | None = None, + rights_holder: dict | None = None, +) -> dict: + """Assemble a manifest document (the Layer 1 artifact).""" + m: dict = { + "manifest_version": "0.1", + "weights_hash": weights_hash, + "builder": {"identity": builder_id, "signing_key": "ed25519:demo"}, + "release_terms": { + "license": license_text, + "permitted_derivatives": "fine-tune-only", + "derivatives": derivatives, # machine-checkable (none|fine-tune-only|unrestricted) + "permitted_environments": ["enterprise-governed-enclave"], + }, + "release_policy": { + "required_assurance_tier": "hardware-attested", + "trusted_time_source": "secure-tsc", + "required_hw_platform": ["amd-sev-snp", "nvidia-cc-gpu"], + "required_gpu_measurement": {"rim_pin": "nvidia-rim:demo-golden"}, + "required_serving_image": { + "signer": "ed25519:demo", + "release_rule": "prefer-current", + "accepted_measurements": [ + {"measurement": serving_measurement, "status": "current"} + ], + }, + "attestation_revocation_check": "live-per-release, max-cache-age: short-window", + "revocation_authority": "builder-and-opaque-joint", + }, + "custody": { + "custodian": custodian_id, + "custodian_type": "customer-self-custody", + "kbs_image": {"measurement": sha256(b"reference-kbs-image"), "signer": "ed25519:demo"}, + "enclave_id": "did:example:enterprise-enclave-01", + "attestation_cadence": "1h", + }, + # State the two things this whole demo turns on, rather than leave them + # implicit: the base is public, and one org holds both roles. + "base_confidentiality": "open", + "deployment_model": "byom-symmetric", + } + if derived_from is not None: + m["derived_from"] = derived_from + if rights_holder is not None: + m["rights_holder"] = rights_holder + return m + + +def sign(manifest: WeightCustodyManifest, keypair, role: str, signer: str) -> dict: + return Ed25519Signer(keypair).sign(manifest.unsigned_dict(), role=role, signer=signer) + + +def main() -> None: + # In the open-weight case the "builder" and "custodian" collapse into the + # enterprise's own model-governance function (the symmetric BYOM case, + # design principle 4). It certifies which checkpoint + serving stack are + # approved internally, and it runs them. We model that with two keys held by + # the same governance org. + gov_builder = generate_ed25519() + gov_custodian = generate_ed25519() + + rule("Open-weight model: base weights are PUBLIC") + # Pretend this blob is a downloaded checkpoint (in reality: your + # Llama-3.1 / Mistral / SmolLM safetensors). We hash the real bytes. + checkpoint = b"" + base_hash = sha256(checkpoint) + serving = sha256(b"vllm-0.6.3 + policy-bundle-v2 (the certified serving stack)") + print("base weights_hash :", base_hash) + print("license : Llama-3.1-Community (usage + scale restrictions)") + print("NOTE: encrypting a *public* base protects nothing. The mechanism below") + print(" does INTEGRITY + LICENSE work here, not secrecy.") + + # ---- Step 0: certify the base checkpoint (integrity + license) ---------- + rule("Step 0 - Certify the base: manifest (integrity + license), jointly signed") + base_doc = build_manifest( + weights_hash=base_hash, + license_text="Llama-3.1-Community", + serving_measurement=serving, + builder_id="acme-model-governance", + custodian_id="acme-model-governance", + derivatives="fine-tune-only", + ) + base = WeightCustodyManifest.model_validate(waive_mock_verification(base_doc)) + base = base.with_signatures([ + sign(base, gov_builder, "builder", "acme-model-governance"), + sign(base, gov_custodian, "custodian", "acme-model-governance"), + ]) + print("manifest binds: weights_hash, the certified serving image, and the license.") + print("signed jointly by the governance function as builder + custodian.") + + # ---- Step 1: verify the manifest (provenance) --------------------------- + rule("Step 1 - Verify the manifest (is this the certified checkpoint?)") + ctx = VerificationContext() + ctx.add_key(gov_builder.public_bytes) + ctx.add_key(gov_custodian.public_bytes) + result = verify_manifest(base, ctx) + print("manifest signature valid:", result.ok, " (integrity, not secrecy)") + # The verifier does not block on base_confidentiality; it tells you, in + # words, what this manifest is and is not protecting. For an open base that + # is exactly the disclaimer you want on the record. + for note in result.notes: + print(" note:", note) + + # ---- Step 2: attestation-gated load ------------------------------------- + rule("Step 2 - Attestation gate: only load the CERTIFIED serving stack") + kbs = KeyBrokerService( + {base.weights_hash: b"loading-key-protects-no-secret-here"}, + # weight-custody-manifest 0.27.0 refuses to release unless the manifest's + # identity is pinned out of band. Without it, a caller could present an + # attacker-authored policy that reused a weights hash the broker already + # held and be released against terms nobody agreed. + trusted_manifest_identities=[manifest_identity(base)], + ) + challenge = kbs.issue_challenge() + evidence = SoftwareProvider().produce( # a REAL enclave would produce a hardware quote + challenge, + serving_image_measurement=serving, + gpu_measurement="nvidia-rim:demo-golden", + ) + decision = kbs.verify_and_release(base, evidence) + print("gate released:", decision.released) + print("what the gate enforced: genuine attestation nonce, the approved platform,") + print("and a serving-image measurement matching what governance signed - so a") + print("silently modified fork of the public weights would NOT load.") + + # ---- Step 3: runtime custody (the kill switch) -------------------------- + rule("Step 3 - Wipe-on-lapse custody (kill switch: recall / license violation)") + session = EnclaveSession.from_release(base, decision) + session.use_key() + print("serving under a 1h cadence; time_floor =", session.time_floor.value) + print("kill-switch reason has shifted: not 'theft detected' but a safety recall,") + print("a license breach, or governance pulling a misbehaving checkpoint.") + + # ---- Step 4: license / field-of-use (technical checkpoint) -------------- + rule("Step 4 - License is a technical release condition, not just contract text") + print("release_terms.license =", base.release_terms.license) + print("the environment is only marked releasable under the disclosed,") + print("license-conforming configuration bound into the signed manifest.") + + # ---- Step 5: fine-tune -> the DERIVATIVE is the real asset --------------- + rule("Step 5 - Fine-tune on proprietary data: the derivative is novel IP") + derivative_weights = checkpoint + b"<+ acme proprietary trading-desk fine-tune>" + deriv_hash = sha256(derivative_weights) + deriv_doc = build_manifest( + weights_hash=deriv_hash, + license_text="Llama-3.1-Community + Acme-proprietary-derivative", + serving_measurement=serving, + builder_id="acme-model-governance", + custodian_id="acme-model-governance", + derivatives="none", # Acme does not permit derivatives OF its derivative + derived_from=base.weights_hash, + rights_holder={"base": "meta", "derivative": "acme"}, + ) + deriv = WeightCustodyManifest.model_validate(waive_mock_verification(deriv_doc)) + deriv = deriv.with_signatures([ + sign(deriv, gov_builder, "builder", "acme-model-governance"), + sign(deriv, gov_custodian, "custodian", "acme-model-governance"), + ]) + print("derivative weights_hash:", deriv_hash) + print("derived_from :", deriv.derived_from) + print("rights_holder :", {"base": deriv.rights_holder.base, "derivative": deriv.rights_holder.derivative}) + print("THIS never existed publicly. It is the whole reason to run the stack.") + + # ---- Step 6: lineage + revocation on the derivative --------------------- + rule("Step 6 - Verify lineage (derivative -> public base root)") + manifests = {base.weights_hash: base, deriv.weights_hash: deriv} + lineage = verify_lineage(manifests, deriv.weights_hash) + print("lineage ok :", lineage.ok) + print("chain :", " -> ".join(h.split(':')[1][:12] + '...' for h in lineage.chain)) + print("depth :", lineage.depth, " root is a base manifest:", is_root(base)) + + rule("What was doing real work") + print("moot for a public base : encrypt-at-rest secrecy of the base weights") + print("real work : integrity/provenance (0,1,2), license (0,4),") + print(" derivative custody + lineage (5,6), kill switch (3)") + print("\nSame six steps as the closed-model flow; the purpose of half of them flipped.") + + +if __name__ == "__main__": + main() diff --git a/examples/weight-custody-manifest/post_quantum.py b/examples/weight-custody-manifest/post_quantum.py new file mode 100644 index 00000000..a5e99222 --- /dev/null +++ b/examples/weight-custody-manifest/post_quantum.py @@ -0,0 +1,104 @@ +"""Post-quantum manifest signing: ML-DSA-65 and an Ed25519 + ML-DSA-65 hybrid. + + python post_quantum.py + +Real WCM code, no hardware. A weight-custody manifest is a long-lived artifact: +a signature that is safe today must still be safe when a model it governs is +still deployed years out. WCM supports FIPS 204 ML-DSA-65 (post-quantum) and a +hybrid that requires BOTH a classical Ed25519 signature and an ML-DSA-65 one, so +it stays valid unless an attacker breaks both. This signs and verifies a manifest +under each profile. +""" +from __future__ import annotations + +import hashlib + +from wcm import ( + HybridSigner, + MlDsa65Signer, + VerificationContext, + WeightCustodyManifest, + generate_hybrid, + generate_ml_dsa65, + verify_manifest, +) + + +def rule(title: str) -> None: + print(f"\n{'=' * 70}\n{title}\n{'=' * 70}") + + +def sha256(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def manifest_doc() -> dict: + serving = sha256(b"measured serving stack") + return { + "manifest_version": "0.1", + "weights_hash": sha256(b"the model weights"), + "builder": {"identity": "frontier-lab", "signing_key": "pq:builder"}, + "release_terms": { + "license": "deployment-agreement", + "permitted_derivatives": "fine-tune-only", + "permitted_environments": ["attested-enclave"], + }, + "release_policy": { + "required_assurance_tier": "hardware-attested", + "trusted_time_source": "secure-tsc", + "required_hw_platform": ["amd-sev-snp"], + "required_serving_image": { + "signer": "pq:builder", + "release_rule": "prefer-current", + "accepted_measurements": [{"measurement": serving, "status": "current"}], + }, + }, + "custody": { + "custodian": "customer-platform", + "custodian_type": "customer-self-custody", + "kbs_image": {"measurement": sha256(b"kbs image"), "signer": "pq:builder"}, + "enclave_id": "did:example:enclave", + "attestation_cadence": "1h", + }, + } + + +def main() -> int: + rule("Profile 1 - ML-DSA-65 (FIPS 204, post-quantum)") + b_key, c_key = generate_ml_dsa65(), generate_ml_dsa65() + m = WeightCustodyManifest.model_validate(manifest_doc()) + m = m.with_signatures([ + MlDsa65Signer(b_key).sign(m.unsigned_dict(), role="builder", signer="frontier-lab"), + MlDsa65Signer(c_key).sign(m.unsigned_dict(), role="custodian", signer="customer-platform"), + ]) + ctx = VerificationContext() + ctx.add_ml_dsa65_key(b_key.public_bytes) + ctx.add_ml_dsa65_key(c_key.public_bytes) + result = verify_manifest(m, ctx) + print("algorithm :", ", ".join(sorted({s.algorithm.value for s in m.signatures}))) + print("manifest verifies :", result.ok) + + rule("Profile 2 - Ed25519 + ML-DSA-65 hybrid (valid only if BOTH hold)") + b_hy, c_hy = generate_hybrid(), generate_hybrid() + m2 = WeightCustodyManifest.model_validate(manifest_doc()) + m2 = m2.with_signatures([ + HybridSigner(b_hy).sign(m2.unsigned_dict(), role="builder", signer="frontier-lab"), + HybridSigner(c_hy).sign(m2.unsigned_dict(), role="custodian", signer="customer-platform"), + ]) + ctx2 = VerificationContext() + ctx2.add_hybrid_key(b_hy.ed25519.public_bytes, b_hy.ml_dsa65.public_bytes) + ctx2.add_hybrid_key(c_hy.ed25519.public_bytes, c_hy.ml_dsa65.public_bytes) + result2 = verify_manifest(m2, ctx2) + print("algorithm :", ", ".join(sorted({s.algorithm.value for s in m2.signatures}))) + print("manifest verifies :", result2.ok) + + rule("Why both are offered") + print("ML-DSA-65 removes the quantum risk to the manifest signature outright.") + print("The hybrid is the conservative migration path: it keeps the classical") + print("guarantee AND adds the PQ one, so it stays valid unless an attacker breaks") + print("both schemes. Same manifest, same verifier, selectable per deployment.") + return 0 if (result.ok and result2.ok) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/weight-custody-manifest/provenance_model_signing.py b/examples/weight-custody-manifest/provenance_model_signing.py new file mode 100644 index 00000000..7518c74d --- /dev/null +++ b/examples/weight-custody-manifest/provenance_model_signing.py @@ -0,0 +1,133 @@ +"""Provenance interop: WCM custody on top of an OpenSSF model-signing signature. + + pip install "weight-custody-manifest[model-signing]" + python provenance_model_signing.py + +Real WCM code, no hardware. OpenSSF model-signing (Sigstore) answers "who signed +this artifact and does it still match?"; WCM answers "release the key only into an +attested enclave, wipe it on lapse, and carry the lineage." They compose: a WCM +manifest carries a signed `provenance.model_signing` reference, and +`verify_provenance` cryptographically verifies that model-signing signature and +binds it to the manifest. WCM is the custody-and-release layer ON TOP of the +signing layer, not a competitor to it. + +This signs a model file with model-signing (using a local EC key, so no Sigstore +account or network is needed), records the model-signing digest in a WCM +manifest, and verifies the whole chain. +""" +from __future__ import annotations + +import hashlib +import pathlib +import tempfile + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from model_signing import signing + +from wcm import ( + Ed25519Signer, + WeightCustodyManifest, + generate_ed25519, + model_signing_digest, + verify_provenance, +) + + +def rule(title: str) -> None: + print(f"\n{'=' * 70}\n{title}\n{'=' * 70}") + + +def sha256(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def build_manifest(weights_hash: str, signed_digest: str) -> WeightCustodyManifest: + serving = sha256(b"measured serving stack") + doc = { + "manifest_version": "0.1", + "weights_hash": weights_hash, + "builder": {"identity": "frontier-lab", "signing_key": "ed25519:lab"}, + "release_terms": { + "license": "deployment-agreement", + "permitted_derivatives": "fine-tune-only", + "permitted_environments": ["attested-enclave"], + }, + "release_policy": { + "required_assurance_tier": "hardware-attested", + "trusted_time_source": "secure-tsc", + "required_hw_platform": ["amd-sev-snp"], + "required_serving_image": { + "signer": "ed25519:lab", + "release_rule": "prefer-current", + "accepted_measurements": [{"measurement": serving, "status": "current"}], + }, + }, + "custody": { + "custodian": "customer", + "custodian_type": "customer-self-custody", + "kbs_image": {"measurement": sha256(b"kbs image"), "signer": "ed25519:lab"}, + "enclave_id": "did:example:enclave", + "attestation_cadence": "1h", + }, + # The provenance reference, under the joint signature. + "provenance": { + "model_signing": { + "method": "openssf-model-signing", + "signed_digest": signed_digest, + "transparency": "sigstore-rekor", + "signer": "release-engineering@frontier-lab", + } + }, + } + b, c = generate_ed25519(), generate_ed25519() + m = WeightCustodyManifest.model_validate(doc) + return m.with_signatures([ + Ed25519Signer(b).sign(m.unsigned_dict(), role="builder", signer="frontier-lab"), + Ed25519Signer(c).sign(m.unsigned_dict(), role="custodian", signer="customer"), + ]) + + +def main() -> int: + with tempfile.TemporaryDirectory() as td: + d = pathlib.Path(td) + model_path = d / "model.safetensors" + model_path.write_bytes(b"") + sig_path = d / "model.sig" + priv_path = d / "signer.key" + pub_path = d / "signer.pub" + + rule("Step 1 - Sign the model with OpenSSF model-signing (local EC key)") + key = ec.generate_private_key(ec.SECP256R1()) + priv_path.write_bytes(key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + )) + pub_path.write_bytes(key.public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo + )) + signing.Config().use_elliptic_key_signer(private_key=priv_path).sign(model_path, sig_path) + digest = model_signing_digest(model_path) + print("model-signing signature written:", sig_path.name) + print("model-signing digest :", digest[:26], "...") + + rule("Step 2 - Record it in a WCM manifest and verify the whole chain") + manifest = build_manifest(sha256(model_path.read_bytes()), digest) + result = verify_provenance(manifest, model_path, sig_path, public_key=pub_path) + print("provenance verified :", result.verified, + "" if result.verified else f"({result.reason})") + + rule("Step 3 - A manifest that claims the wrong digest fails the binding") + wrong = build_manifest(sha256(model_path.read_bytes()), sha256(b"a different artifact")) + bad = verify_provenance(wrong, model_path, sig_path, public_key=pub_path) + print("provenance verified :", bad.verified, f"({bad.reason})") + + print() + print("So the WCM manifest cannot claim a model-signing provenance it does not") + print("actually match: verify_provenance checks the signature AND the digest.") + return 0 if (result.verified and not bad.verified) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/weight-custody-manifest/quote_verification.py b/examples/weight-custody-manifest/quote_verification.py new file mode 100644 index 00000000..b48777a5 --- /dev/null +++ b/examples/weight-custody-manifest/quote_verification.py @@ -0,0 +1,124 @@ +"""Quote verification: the KBS-side trust decision, and where real vendors plug in. + + python quote_verification.py + +Real WCM code, no hardware. Getting structured evidence to the gate is not the +trust decision. The trust decision is cryptographic: does the attestation quote +carry a valid hardware signature, does that key chain to a root the verifier +trusts, and is the quote bound to THIS challenge nonce? This drives +`QuoteVerifier` through the happy path and every failure mode against a synthetic +PKI (real cryptography), then points at the real vendor parsers that produce the +same `ParsedQuote` from genuine binary quotes. +""" +from __future__ import annotations + +import base64 +import hashlib +import json +from datetime import datetime, timedelta, timezone + +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.x509.oid import NameOID + +from wcm import ( + JsonQuoteParser, + QuoteVerifier, + SnpQuoteParser, + TrustStore, + build_gpu_verifier, + verify_tdx_quote, +) + +NOW = datetime(2026, 1, 1, tzinfo=timezone.utc) +NONCE = "ab" * 32 + + +def rule(title: str) -> None: + print(f"\n{'=' * 70}\n{title}\n{'=' * 70}") + + +def _pki(): + root_key = ec.generate_private_key(ec.SECP256R1()) + leaf_key = ec.generate_private_key(ec.SECP256R1()) + + def cert(subject, subj_key, issuer, issuer_key, ca): + b = ( + x509.CertificateBuilder() + .subject_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, subject)])) + .issuer_name(x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, issuer)])) + .public_key(subj_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(NOW - timedelta(days=1)) + .not_valid_after(NOW + timedelta(days=365)) + ) + if ca: + b = b.add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True) + return b.sign(issuer_key, hashes.SHA256()) + + root = cert("vendor-root", root_key, "vendor-root", root_key, True) + leaf = cert("attestation-key", leaf_key, "vendor-root", root_key, False) + return root, leaf, leaf_key + + +def _quote(leaf, leaf_key, nonce_hex: str) -> str: + report_body = hashlib.sha256(bytes.fromhex(nonce_hex)).digest() + bytes(32) + b"measurement" + signature = leaf_key.sign(report_body, ec.ECDSA(hashes.SHA256())) + doc = { + "report_b64": base64.b64encode(report_body).decode(), + "signature_b64": base64.b64encode(signature).decode(), + "leaf_pem": leaf.public_bytes(serialization.Encoding.PEM).decode(), + "intermediates_pem": [], + "report_data_offset": 0, + } + return base64.b64encode(json.dumps(doc).encode()).decode() + + +def _verifier(root) -> QuoteVerifier: + ts = TrustStore() + ts.add_root(root) + return QuoteVerifier(JsonQuoteParser(), ts) + + +def main() -> int: + root, leaf, leaf_key = _pki() + quote = _quote(leaf, leaf_key, NONCE) + v = _verifier(root) + + rule("Happy path - chain, signature, and nonce binding all check") + r = v.verify(quote, expected_nonce=NONCE, now=NOW) + print("verified:", r.verified, " leaf:", r.leaf_subject) + + rule("Untrusted root - the quote does not chain to a root we trust") + other_root, _, _ = _pki() + r = _verifier(other_root).verify(quote, expected_nonce=NONCE, now=NOW) + print("verified:", r.verified, " reason:", r.reason) + + rule("Tampered report - the signature no longer verifies") + doc = json.loads(base64.b64decode(quote)) + body = bytearray(base64.b64decode(doc["report_b64"])) + body[-1] ^= 0xFF + doc["report_b64"] = base64.b64encode(bytes(body)).decode() + tampered = base64.b64encode(json.dumps(doc).encode()).decode() + r = v.verify(tampered, expected_nonce=NONCE, now=NOW) + print("verified:", r.verified, " reason:", r.reason) + + rule("Nonce mismatch - a replayed quote is not bound to this challenge") + r = v.verify(quote, expected_nonce="cd" * 32, now=NOW) + print("verified:", r.verified, " reason:", r.reason) + + rule("Where the real vendors plug in") + print("The machinery above is vendor-agnostic. A real deployment swaps the") + print("JsonQuoteParser + synthetic root for a vendor parser + the vendor's real root:") + print(f" AMD SEV-SNP : {SnpQuoteParser.__name__} (VCEK -> ASK -> ARK), validated on live Azure") + print(f" Intel TDX : {verify_tdx_quote.__name__} (DCAP v4 -> Intel SGX Root CA), validated on GCP") + print(f" NVIDIA H100 : {build_gpu_verifier.__name__} (GPU report -> NVIDIA device root)") + print("None of this closes the key-extraction hole (open question 8.8): a") + print("physically-extracted key produces a genuinely valid signature. It raises") + print("the bar to a real hardware signature; it does not beat a hardware owner.") + return 0 if r is not None else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/weight-custody-manifest/real_lora_custody.py b/examples/weight-custody-manifest/real_lora_custody.py new file mode 100644 index 00000000..bb06fa32 --- /dev/null +++ b/examples/weight-custody-manifest/real_lora_custody.py @@ -0,0 +1,329 @@ +"""Train, encrypt, verify, and locally load a real LoRA derivative. + +The public base model is not treated as secret. The saved PEFT adapter is the +private derivative: its complete file inventory is hashed, packed with stable +metadata, encrypted with AES-256-GCM, and only unpacked after authenticated +decryption and an exact inventory check. + +Heavy dependencies and a model download are needed only for the launch run:: + + pip install -r requirements-lora.txt + python real_lora_custody.py --output validation/lora --infer +""" +from __future__ import annotations + +import argparse +import base64 +import hashlib +import io +import json +import os +import pathlib +import secrets +import shutil +import tarfile +import tempfile +from typing import Iterable + +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from model_signing import signing as model_signing + +from wcm import ( + Ed25519Signer, + KeyBrokerService, + SoftwareProvider, + VerificationContext, + WeightCustodyManifest, + artifact_files, + generate_ed25519, + generate_transport_keypair, + model_signing_digest, + open_sealed, + verify_manifest, + verify_provenance, +) + +from _mock_attestation import waive_mock_verification + +from real_open_model import build_manifest, resolve_artifact, sha256_artifact + +FORMAT = "wcm-encrypted-derivative/v1" + + +def sign_artifact(root: pathlib.Path, output: pathlib.Path) -> tuple[str, pathlib.Path, pathlib.Path]: + """Create a detached OpenSSF signature, retaining no private signing key.""" + signature_path = output / "adapter.model-signing.sig" + public_key_path = output / "adapter.model-signing.pub.pem" + key = ec.generate_private_key(ec.SECP256R1()) + public_key_path.write_bytes(key.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + )) + with tempfile.TemporaryDirectory(prefix="wcm-model-signing-") as temp: + private_key_path = pathlib.Path(temp) / "signer.key" + private_key_path.write_bytes(key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + )) + model_signing.Config().use_elliptic_key_signer( + private_key=private_key_path + ).sign(root, signature_path) + return model_signing_digest(root), signature_path, public_key_path + + +def _relative_files(root: pathlib.Path) -> Iterable[tuple[pathlib.Path, str]]: + for path in artifact_files(root, follow_symlinks=True): + yield path, path.relative_to(root).as_posix() + + +def pack_artifact(root: pathlib.Path) -> bytes: + """Create stable tar bytes for a directory without timestamps or owners.""" + if not root.is_dir(): + raise ValueError("the LoRA artifact must be a directory") + output = io.BytesIO() + with tarfile.open(fileobj=output, mode="w:") as archive: + for path, relative in _relative_files(root): + info = tarfile.TarInfo(relative) + info.size = path.stat().st_size + info.mode = 0o644 + info.mtime = info.uid = info.gid = 0 + with path.open("rb") as handle: + archive.addfile(info, handle) + return output.getvalue() + + +def encrypt_artifact(root: pathlib.Path, key: bytes) -> dict: + if len(key) != 32: + raise ValueError("the derivative data-encryption key must be 32 bytes") + digest = sha256_artifact(root) + nonce = secrets.token_bytes(12) + aad = json.dumps( + {"format": FORMAT, "artifact_digest": digest}, + sort_keys=True, + separators=(",", ":"), + ).encode() + ciphertext = AESGCM(key).encrypt(nonce, pack_artifact(root), aad) + return { + "format": FORMAT, + "artifact_digest": digest, + "nonce_b64": base64.b64encode(nonce).decode(), + "ciphertext_b64": base64.b64encode(ciphertext).decode(), + } + + +def encrypt_and_remove_staging(root: pathlib.Path, key: bytes) -> dict: + """Encrypt a staging artifact and remove its plaintext on every exit path.""" + try: + return encrypt_artifact(root, key) + finally: + if root.exists(): + shutil.rmtree(root) + + +def decrypt_artifact(envelope: dict, key: bytes, destination: pathlib.Path) -> str: + """Authenticate, safely unpack, then verify the exact derivative inventory.""" + if envelope.get("format") != FORMAT: + raise ValueError("unsupported encrypted derivative format") + digest = envelope.get("artifact_digest") + if not isinstance(digest, str) or not digest.startswith("sha256:"): + raise ValueError("encrypted derivative is missing its artifact digest") + aad = json.dumps( + {"format": FORMAT, "artifact_digest": digest}, + sort_keys=True, + separators=(",", ":"), + ).encode() + plaintext = AESGCM(key).decrypt( + base64.b64decode(envelope["nonce_b64"], validate=True), + base64.b64decode(envelope["ciphertext_b64"], validate=True), + aad, + ) + destination.mkdir(parents=True, exist_ok=False) + with tarfile.open(fileobj=io.BytesIO(plaintext), mode="r:") as archive: + for member in archive.getmembers(): + target = (destination / member.name).resolve() + if destination.resolve() not in target.parents or not member.isfile(): + raise ValueError("encrypted derivative contains an unsafe archive member") + archive.extractall(destination, filter="data") + observed = sha256_artifact(destination) + if observed != digest: + raise ValueError(f"decrypted derivative digest mismatch: {observed} != {digest}") + return observed + + +def train_lora(base_path: pathlib.Path, output: pathlib.Path, *, steps: int) -> None: + """Perform a small, deterministic, genuine gradient update and save PEFT files.""" + try: + import torch + from peft import LoraConfig, TaskType, get_peft_model + from transformers import AutoModelForCausalLM, AutoTokenizer + except ImportError as exc: + raise SystemExit("install requirements-lora.txt to train the derivative") from exc + + torch.manual_seed(20260812) + os.environ["HF_HUB_OFFLINE"] = "1" + tokenizer = AutoTokenizer.from_pretrained(base_path, local_files_only=True) + model = AutoModelForCausalLM.from_pretrained(base_path, local_files_only=True) + config = LoraConfig( + task_type=TaskType.CAUSAL_LM, + r=4, + lora_alpha=8, + lora_dropout=0.0, + target_modules=["q_proj", "v_proj"], + ) + model = get_peft_model(model, config) + model.train() + batch = tokenizer( + "WCM releases a private model derivative only after fresh attestation.", + return_tensors="pt", + ) + optimizer = torch.optim.AdamW(model.parameters(), lr=2e-4) + for _ in range(steps): + optimizer.zero_grad() + loss = model(**batch, labels=batch["input_ids"]).loss + loss.backward() + optimizer.step() + output.mkdir(parents=True, exist_ok=False) + model.save_pretrained(output, safe_serialization=True) + + +def run_inference(base_path: pathlib.Path, adapter_path: pathlib.Path) -> str: + try: + from peft import PeftModel + from transformers import AutoModelForCausalLM, AutoTokenizer + except ImportError as exc: + raise SystemExit("install requirements-lora.txt to run inference") from exc + os.environ["HF_HUB_OFFLINE"] = "1" + tokenizer = AutoTokenizer.from_pretrained(base_path, local_files_only=True) + base = AutoModelForCausalLM.from_pretrained(base_path, local_files_only=True) + model = PeftModel.from_pretrained(base, adapter_path, local_files_only=True) + inputs = tokenizer("Confidential model custody", return_tensors="pt") + output = model.generate(**inputs, max_new_tokens=12, do_sample=False) + return tokenizer.decode(output[0], skip_special_tokens=True) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--model", default="HuggingFaceTB/SmolLM2-135M-Instruct") + parser.add_argument("--revision", default="main") + parser.add_argument("--local") + parser.add_argument("--output", type=pathlib.Path, required=True) + parser.add_argument("--steps", type=int, default=1) + parser.add_argument("--infer", action="store_true") + args = parser.parse_args() + if args.steps < 1: + parser.error("--steps must be positive") + + base_path, model_id = resolve_artifact(args) + if not base_path.is_dir(): + parser.error("LoRA training requires a complete base-model directory") + base_digest = sha256_artifact(base_path) + adapter_path = args.output / "adapter-plaintext" + train_lora(base_path, adapter_path, steps=args.steps) + + provenance_digest, provenance_signature, provenance_public_key = sign_artifact( + adapter_path, args.output + ) + + key = AESGCM.generate_key(bit_length=256) + envelope = encrypt_and_remove_staging(adapter_path, key) + envelope_path = args.output / "adapter.encrypted.json" + envelope_path.write_text(json.dumps(envelope, indent=2), encoding="utf-8") + + # Bind the real derivative digest and lineage into a jointly signed WCM + # manifest, then release its DEK only as ciphertext sealed to the workload's + # ephemeral transport key. This local tier deliberately uses software + # evidence; the identical manifest/KBS path is fed hardware evidence later. + builder, custodian = generate_ed25519(), generate_ed25519() + serving = "sha256:" + hashlib.sha256(b"wcm-local-lora-serving-stack-v1").hexdigest() + manifest_doc = build_manifest( + weights_hash=envelope["artifact_digest"], + license_text="Apache-2.0 + OPAQUE-private-derivative", + serving=serving, + builder_id="opaque-wcm-builder", + custodian_id="opaque-wcm-custodian", + derivatives="none", + derived_from=base_digest, + rights_holder={"base": model_id, "derivative": "OPAQUE"}, + ) + manifest_doc["provenance"] = { + "model_signing": { + "method": "openssf-model-signing", + "signed_digest": provenance_digest, + "transparency": "local-key; publication pending", + "signer": "opaque-wcm-builder", + } + } + manifest = WeightCustodyManifest.model_validate(waive_mock_verification(manifest_doc)) + manifest = manifest.with_signatures([ + Ed25519Signer(builder).sign( + manifest.unsigned_dict(), role="builder", signer="opaque-wcm-builder" + ), + Ed25519Signer(custodian).sign( + manifest.unsigned_dict(), role="custodian", signer="opaque-wcm-custodian" + ), + ]) + context = VerificationContext() + context.add_key(builder.public_bytes) + context.add_key(custodian.public_bytes) + if not verify_manifest(manifest, context).ok: + raise RuntimeError("joint derivative-manifest verification failed") + + transport_private, transport_public = generate_transport_keypair() + kbs = KeyBrokerService( + {manifest.weights_hash: key}, require_channel_binding=True + ) + challenge = kbs.issue_challenge() + evidence = SoftwareProvider().produce( + challenge, + serving_image_measurement=serving, + gpu_measurement="nvidia-rim:demo-golden", + transport_public_key=transport_public, + ) + release = kbs.verify_and_release(manifest, evidence) + if not release.released or release.key is not None or release.sealed_key is None: + raise RuntimeError("KBS did not return a transport-sealed derivative key") + released_key = open_sealed(release.sealed_key, transport_private) + + manifest_path = args.output / "derivative.manifest.json" + manifest_path.write_text( + json.dumps(manifest.model_dump(mode="json", exclude_none=True), indent=2), + encoding="utf-8", + ) + verification_keys_path = args.output / "manifest-verification-keys.json" + verification_keys_path.write_text(json.dumps({ + "builder_ed25519_public_b64": base64.b64encode(builder.public_bytes).decode(), + "custodian_ed25519_public_b64": base64.b64encode(custodian.public_bytes).decode(), + }, indent=2), encoding="utf-8") + print("base model :", model_id) + print("base digest :", base_digest) + print("derivative digest:", envelope["artifact_digest"]) + print("encrypted artifact:", envelope_path) + print("signed manifest :", manifest_path) + print("verification keys:", verification_keys_path) + print("OpenSSF signature:", provenance_signature) + print("OpenSSF public key:", provenance_public_key) + print("KBS release : sealed to ephemeral transport key (software evidence tier)") + + with tempfile.TemporaryDirectory(prefix="wcm-lora-") as temp: + verified = pathlib.Path(temp) / "verified-adapter" + decrypt_artifact(envelope, released_key, verified) + provenance = verify_provenance( + manifest, + verified, + provenance_signature, + public_key=provenance_public_key, + ) + if not provenance.verified: + raise RuntimeError(f"OpenSSF provenance verification failed: {provenance.reason}") + print("OpenSSF provenance: verified against decrypted exact artifact") + print("verified local derivative before load:", verified) + if args.infer: + print("model output:", repr(run_inference(base_path, verified))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/weight-custody-manifest/real_open_model.py b/examples/weight-custody-manifest/real_open_model.py new file mode 100644 index 00000000..4e9640ac --- /dev/null +++ b/examples/weight-custody-manifest/real_open_model.py @@ -0,0 +1,313 @@ +"""End-to-end Weight Custody Manifest flow on a REAL open-weight model, locally. + +Downloads a pinned snapshot of a real open model, hashes its ACTUAL artifacts, +and runs the whole WCM +flow over those bytes. The ONLY mocked part is the hardware attestation +(SoftwareProvider): a laptop has no SEV-SNP/TDX/H100, and that hardware-rooted +step is the one we validate separately on real cloud silicon. Everything else is +real WCM code over real weights: + + integrity/provenance -> hash the actual safetensors, bind it in the manifest + joint signing -> real Ed25519 builder + custodian signatures + release gate -> the KBS composite-verify logic (software evidence) + wipe-on-lapse -> the kill switch + license-as-condition -> the model's license bound into the signed manifest + derivative custody -> a fine-tune's weights get their own manifest + lineage + +Usage: + pip install huggingface_hub safetensors + python real_open_model.py # SmolLM2-135M (~270MB) + python real_open_model.py --model --license "" + python real_open_model.py --revision + python real_open_model.py --local path/to/model-directory +""" +from __future__ import annotations + +import argparse +import hashlib +import os +import pathlib +import shutil +import tempfile + +from wcm import ( + artifact_digest, + artifact_files, + combine_shares, # noqa: F401 - kept importable for the curious + Ed25519Signer, + EnclaveSession, + generate_ed25519, + is_root, + KeyBrokerService, + manifest_identity, + SoftwareProvider, + VerificationContext, + verify_lineage, + verify_manifest, + WeightCustodyManifest, +) + +from _mock_attestation import waive_mock_verification + + +def rule(title: str) -> None: + print(f"\n{'=' * 72}\n{title}\n{'=' * 72}") + + +def sha256_artifact(path: pathlib.Path) -> str: + """Hash a file or a complete model directory. + + A thin wrapper over ``wcm.artifact_digest``, which is where this recipe + lives as of weight-custody-manifest 0.27.0. It used to be implemented here, + and copied into two Marketplace integrations, with nothing keeping the three + in step; a digest recipe that exists three times stops being one recipe, and + the drift shows up as ``weights_hash`` not matching, which reads as tampered + weights. + + ``follow_symlinks=True`` because a Hugging Face snapshot directory is a + symlink tree: ``snapshot_download`` populates ``snapshots//`` with + links into a content-addressed ``blobs/`` directory in the same cache. The + SDK refuses symlinks by default, which is right for an artifact somebody + handed you and wrong for a cache you just populated yourself. + """ + return str(artifact_digest(path, follow_symlinks=True)) + + +def tampered_digest(path: pathlib.Path) -> str: + """The digest a silently modified fork of these weights would have. + + Copies the artifact, flips one byte, hashes the copy and throws it away, so + the model you downloaded is never written to. There is a test asserting the + original bytes are unchanged afterwards. + + This used to be a ``flip_first_byte`` flag threaded through the hash + function, which avoided the copy but meant maintaining a second hashing path + that existed only to fake tampering. Copying costs disk on a demo that has + already downloaded the model, and buys a demonstration where the bytes + genuinely differ rather than one where the arithmetic was nudged. + """ + with tempfile.TemporaryDirectory() as tmp: + staged = pathlib.Path(tmp) / "fork" + if path.is_dir(): + shutil.copytree(path, staged, symlinks=False) + else: + staged.mkdir() + shutil.copy2(path, staged / path.name) + + target = artifact_files(staged, follow_symlinks=True)[0] + data = bytearray(target.read_bytes()) + if not data: + raise ValueError(f"cannot tamper an empty file: {target}") + data[0] ^= 0xFF + target.write_bytes(bytes(data)) + + return str(artifact_digest(staged, follow_symlinks=True)) + + +def run_inference(verified_path: pathlib.Path) -> None: + """Optionally load the model and generate, so the certified serving stack is + a real running model, not a placeholder. Lazy imports; skips if unavailable.""" + try: + from transformers import AutoModelForCausalLM, AutoTokenizer + except ImportError: + print(" transformers not installed; `pip install transformers` to enable --infer") + return + print(" loading the model and generating (real serving stack)...") + # The verified local snapshot is the only permissible source after the + # manifest check. These settings make an accidental network fallback fail. + os.environ["HF_HUB_OFFLINE"] = "1" + tok = AutoTokenizer.from_pretrained(verified_path, local_files_only=True) + model = AutoModelForCausalLM.from_pretrained(verified_path, local_files_only=True) + ids = tok("Confidential computing protects", return_tensors="pt") + out = model.generate(**ids, max_new_tokens=12, do_sample=False) + print(" model output:", repr(tok.decode(out[0], skip_special_tokens=True))) + + +def resolve_artifact(args: argparse.Namespace) -> tuple[pathlib.Path, str]: + if args.local: + p = pathlib.Path(args.local) + if not p.exists(): + raise SystemExit(f"--local path not found: {p}") + return p, f"local:{p.name}" + try: + from huggingface_hub import HfApi, snapshot_download + except ImportError: + raise SystemExit("pip install huggingface_hub to download a model, or pass --local") + resolved_revision = HfApi().model_info(args.model, revision=args.revision).sha + if not resolved_revision: + raise SystemExit(f"could not resolve {args.model}@{args.revision} to a commit") + print(f"resolved {args.model}@{args.revision} -> {resolved_revision}") + print("downloading the immutable snapshot (cached after first run)...") + path = pathlib.Path(snapshot_download(repo_id=args.model, revision=resolved_revision)) + return path, f"{args.model}@{resolved_revision}" + + +def build_manifest(*, weights_hash, license_text, serving, builder_id, custodian_id, + derivatives, derived_from=None, rights_holder=None) -> dict: + m: dict = { + "manifest_version": "0.1", + "weights_hash": weights_hash, + "builder": {"identity": builder_id, "signing_key": "ed25519:demo"}, + "release_terms": { + "license": license_text, + "permitted_derivatives": "fine-tune-only", + "derivatives": derivatives, + "permitted_environments": ["enterprise-governed-enclave"], + }, + "release_policy": { + "required_assurance_tier": "hardware-attested", + "trusted_time_source": "secure-tsc", + "required_hw_platform": ["amd-sev-snp", "nvidia-cc-gpu"], + "required_gpu_measurement": {"rim_pin": "nvidia-rim:demo-golden"}, + "required_serving_image": { + "signer": "ed25519:demo", + "release_rule": "prefer-current", + "accepted_measurements": [{"measurement": serving, "status": "current"}], + }, + "attestation_revocation_check": "live-per-release, max-cache-age: short-window", + "revocation_authority": "builder-and-opaque-joint", + }, + "custody": { + "custodian": custodian_id, + "custodian_type": "customer-self-custody", + "kbs_image": {"measurement": "sha256:" + "ab" * 32, "signer": "ed25519:demo"}, + "enclave_id": "did:example:enterprise-enclave-01", + "attestation_cadence": "1h", + }, + # The open-weight reframe: base is public, one org holds both roles. + "base_confidentiality": "open", + "deployment_model": "byom-symmetric", + } + if derived_from is not None: + m["derived_from"] = derived_from + if rights_holder is not None: + m["rights_holder"] = rights_holder + return waive_mock_verification(m) + + +def sign(manifest, kp, role, signer): + return Ed25519Signer(kp).sign(manifest.unsigned_dict(), role=role, signer=signer) + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--model", default="HuggingFaceTB/SmolLM2-135M") + ap.add_argument("--revision", default="main", + help="Hugging Face revision; use an immutable commit for launch evidence") + ap.add_argument("--local", default=None, + help="path to a complete local model directory or single model file") + ap.add_argument("--license", default="Apache-2.0") + ap.add_argument("--infer", action="store_true", + help="also load the model and generate (real serving stack; needs transformers)") + args = ap.parse_args() + + artifact_path, model_id = resolve_artifact(args) + files = artifact_files(artifact_path, follow_symlinks=True) + size_mb = sum(p.stat().st_size for p in files) / 1e6 + + rule(f"Real open model: {model_id} ({size_mb:.1f} MB of real weights)") + base_hash = sha256_artifact(artifact_path) + print("artifact path :", artifact_path) + print("artifact files :", len(files)) + print("REAL artifact hash:", base_hash) + print("license :", args.license) + print("NOTE: the base is public, so this protects integrity + license +") + print(" derivative custody, not secrecy. Attestation below is the software") + print(" mock (no TEE on this machine); the hardware path is validated in cloud.") + + gov_builder, gov_custodian = generate_ed25519(), generate_ed25519() + serving = hashlib.sha256(b"vllm + policy-bundle (the certified serving stack)").hexdigest() + serving = "sha256:" + serving + + # ---- Step 0-1: certify + verify the base (integrity + license) ---------- + rule("Step 1 - Certify + verify the base manifest (integrity, provenance)") + base = WeightCustodyManifest.model_validate(build_manifest( + weights_hash=base_hash, license_text=args.license, serving=serving, + builder_id="acme-model-governance", custodian_id="acme-model-governance", + derivatives="fine-tune-only")) + base = base.with_signatures([ + sign(base, gov_builder, "builder", "acme-model-governance"), + sign(base, gov_custodian, "custodian", "acme-model-governance")]) + ctx = VerificationContext() + ctx.add_key(gov_builder.public_bytes) + ctx.add_key(gov_custodian.public_bytes) + result = verify_manifest(base, ctx) + print("manifest signature valid:", result.ok) + for note in result.notes: + print(" note:", note) + + # ---- Step 2: attestation-gated load (only the certified stack) ---------- + rule("Step 2 - Attestation gate (load only the certified serving stack)") + kbs = KeyBrokerService( + {base.weights_hash: b"loading-key-not-a-secret-for-open-weights"}, + # weight-custody-manifest 0.27.0 refuses to release unless the manifest's + # identity is pinned out of band. Without it, a caller could present an + # attacker-authored policy that reused a weights hash the broker already + # held and be released against terms nobody agreed. + trusted_manifest_identities=[manifest_identity(base)], + ) + challenge = kbs.issue_challenge() + evidence = SoftwareProvider().produce( + challenge, serving_image_measurement=serving, gpu_measurement="nvidia-rim:demo-golden") + decision = kbs.verify_and_release(base, evidence) + print("gate released :", decision.released) + print("a silently modified fork of these public weights would NOT load here.") + + # ---- Step 3: wipe-on-lapse custody (kill switch) ------------------------ + rule("Step 3 - Wipe-on-lapse custody (kill switch: recall / license breach)") + session = EnclaveSession.from_release(base, decision) + session.use_key() + print("serving under a 1h cadence; time_floor =", session.time_floor.value) + + # ---- Step 4: license as a technical release condition ------------------- + rule("Step 4 - License is a technical release condition") + print("release_terms.license =", base.release_terms.license) + if args.infer: + if artifact_path.is_file(): + print(" (--infer needs a complete model directory; skipping single-file artifact)") + else: + run_inference(artifact_path) + + # ---- Step 5: fine-tune -> the DERIVATIVE is the real asset --------------- + rule("Step 5 - Fine-tune: the derivative weights get their own custody") + # A real fine-tune would retrain; here we stand in for the *resulting bytes* + # (base weights + your proprietary delta) and hash them, which is what a + # derivative manifest actually binds. + deriv_hash = hashlib.sha256( + base_hash.encode() + b"<+ acme proprietary fine-tune delta>").hexdigest() + deriv_hash = "sha256:" + deriv_hash + deriv = WeightCustodyManifest.model_validate(build_manifest( + weights_hash=deriv_hash, license_text=args.license + " + Acme-derivative", + serving=serving, builder_id="acme-model-governance", + custodian_id="acme-model-governance", derivatives="none", + derived_from=base.weights_hash, rights_holder={"base": model_id, "derivative": "acme"})) + deriv = deriv.with_signatures([ + sign(deriv, gov_builder, "builder", "acme-model-governance"), + sign(deriv, gov_custodian, "custodian", "acme-model-governance")]) + print("derivative weights_hash:", deriv_hash) + print("derived_from :", deriv.derived_from) + + # ---- Step 6: lineage from derivative back to the real base -------------- + rule("Step 6 - Verify lineage (derivative -> real open base)") + lineage = verify_lineage({base.weights_hash: base, deriv.weights_hash: deriv}, deriv.weights_hash) + print("lineage ok :", lineage.ok, " depth:", lineage.depth, " base is root:", is_root(base)) + + # ---- Step 7: integrity, made concrete ----------------------------------- + rule("Step 7 - A silently tampered fork is caught by the hash") + tampered = tampered_digest(artifact_path) + print("certified weights_hash :", base_hash) + print("tampered-fork hash :", tampered) + print("tampered matches manifest? :", tampered == base.weights_hash) + print("the enclave binds the manifest's weights_hash, so weights that do not hash") + print("to it (a poisoned or backdoored fork of these public weights) never load.") + + rule("What was real vs mocked here") + print("REAL : the weights_hash over", model_id, "actual bytes; joint signatures;") + print(" the release-gate logic; wipe-on-lapse; derivative manifest + lineage.") + print("MOCKED : the attestation evidence (no TEE on this machine). The hardware") + print(" root of trust is validated separately (SEV-SNP + TDX, in cloud).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/weight-custody-manifest/refuse_and_wipe.py b/examples/weight-custody-manifest/refuse_and_wipe.py new file mode 100644 index 00000000..753db478 --- /dev/null +++ b/examples/weight-custody-manifest/refuse_and_wipe.py @@ -0,0 +1,151 @@ +"""The 30-second demo: the key refuses to release, and wipe-on-lapse zeroizes it. + +Two moments, real WCM code with a software (mock) attestation provider (no +hardware): + + 1. REFUSE - the KBS will not release the weight-decryption key into an enclave + running an unapproved serving stack. A silently modified fork gets nothing, + so the weights it holds stay encrypted. + 2. WIPE - once released, the key lives only for the attestation cadence. Miss + the re-attestation and the enclave zeroizes it. The key is gone, not + suspended, which bounds worst-case exposure to a single cadence window even + if every revocation signal is blocked. + + pip install weight-custody-manifest + python refuse_and_wipe.py +""" +from __future__ import annotations + +import hashlib +from datetime import datetime, timedelta, timezone + +from wcm import ( + Ed25519Signer, + EnclaveSession, + generate_ed25519, + KeyBrokerService, + KeyWipedError, + manifest_identity, + SoftwareProvider, + WeightCustodyManifest, +) + +from _mock_attestation import waive_mock_verification + + +def sha256(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def banner(text: str) -> None: + print(f"\n{'=' * 64}\n{text}\n{'=' * 64}") + + +def build_manifest(weights_hash: str, serving: str, org: str) -> dict: + return { + "manifest_version": "0.1", + "weights_hash": weights_hash, + "builder": {"identity": org, "signing_key": "ed25519:demo"}, + "release_terms": { + "license": "Frontier-Model-License", + "permitted_derivatives": "fine-tune-only", + "derivatives": "fine-tune-only", + "permitted_environments": ["enterprise-governed-enclave"], + }, + "release_policy": { + "required_assurance_tier": "hardware-attested", + "trusted_time_source": "secure-tsc", + "required_hw_platform": ["amd-sev-snp", "nvidia-cc-gpu"], + "required_gpu_measurement": {"rim_pin": "nvidia-rim:golden"}, + "required_serving_image": { + "signer": "ed25519:demo", + "release_rule": "prefer-current", + "accepted_measurements": [{"measurement": serving, "status": "current"}], + }, + "attestation_revocation_check": "live-per-release, max-cache-age: short-window", + "revocation_authority": "builder-and-opaque-joint", + }, + "custody": { + "custodian": org, + "custodian_type": "customer-self-custody", + "kbs_image": {"measurement": sha256(b"reference-kbs-image"), "signer": "ed25519:demo"}, + "enclave_id": "did:example:enclave-01", + "attestation_cadence": "1h", + }, + "base_confidentiality": "gated-open", + "deployment_model": "builder-to-customer", + } + + +def main() -> None: + org = "frontier-labs" + builder, custodian = generate_ed25519(), generate_ed25519() + + weights_hash = sha256(b"") + approved = sha256(b"vllm-0.6.3 + policy-bundle-v2 (the builder-signed serving stack)") + + doc = build_manifest(weights_hash, approved, org) + manifest = WeightCustodyManifest.model_validate(waive_mock_verification(doc)) + manifest = manifest.with_signatures([ + Ed25519Signer(builder).sign(manifest.unsigned_dict(), role="builder", signer=org), + Ed25519Signer(custodian).sign(manifest.unsigned_dict(), role="custodian", signer=org), + ]) + + kbs = KeyBrokerService( + {weights_hash: b"the-weight-decryption-key"}, + # weight-custody-manifest 0.27.0 refuses to release unless the manifest's + # identity is pinned out of band. Without it, a caller could present an + # attacker-authored policy that reused a weights hash the broker already + # held and be released against terms nobody agreed. + trusted_manifest_identities=[manifest_identity(manifest)], + ) + + # -- Moment 1: REFUSE ------------------------------------------------------ + banner("1. A tampered enclave asks for the key. It gets nothing.") + tampered = sha256(b"vllm-0.6.3 + a BACKDOORED policy bundle") + challenge = kbs.issue_challenge() + bad_evidence = SoftwareProvider().produce( + challenge, + serving_image_measurement=tampered, # not what the builder signed + gpu_measurement="nvidia-rim:golden", + ) + decision = kbs.verify_and_release(manifest, bad_evidence) + print("serving stack : UNAPPROVED (a modified fork)") + print("key released :", decision.released) + print("why :", next(c.detail for c in decision.failures)) + print("-> the weights it holds stay encrypted. The fork cannot decrypt them.") + + # -- The approved enclave gets the key ------------------------------------- + banner("2. The approved enclave attests. The key releases.") + challenge = kbs.issue_challenge() + good_evidence = SoftwareProvider().produce( + challenge, + serving_image_measurement=approved, + gpu_measurement="nvidia-rim:golden", + ) + decision = kbs.verify_and_release(manifest, good_evidence) + print("serving stack : builder-signed and attested") + print("key released :", decision.released) + + # -- Moment 2: WIPE-ON-LAPSE ----------------------------------------------- + banner("3. Miss the re-attestation. The enclave zeroizes the key.") + t0 = datetime(2026, 1, 1, tzinfo=timezone.utc) + session = EnclaveSession.from_release(manifest, decision, now=lambda: t0) + print("cadence :", doc["custody"]["attestation_cadence"], + " time_floor:", session.time_floor.value) + session.use_key(now=t0) + print("served a request : key present") + later = t0 + timedelta(hours=1, minutes=1) # past the 1h window, no re-attest + try: + session.use_key(now=later) + print("served a request : key present") # not reached + except KeyWipedError: + print("re-attest missed : key ZEROIZED") + print("state :", session.state.value, "(gone, not suspended)") + print("-> worst-case exposure is one cadence window, even if revocation is blocked.") + + banner("Refuse. Release only on proof. Wipe on lapse.") + + +if __name__ == "__main__": + main() diff --git a/examples/weight-custody-manifest/requirements-infer.txt b/examples/weight-custody-manifest/requirements-infer.txt new file mode 100644 index 00000000..2204c40c --- /dev/null +++ b/examples/weight-custody-manifest/requirements-infer.txt @@ -0,0 +1,6 @@ +# Run-local extras for real_open_model.py only. These download a real open model +# and, with --infer, load it and generate. Not needed for the three offline demos +# and deliberately not installed in CI (network + heavy). +huggingface_hub +safetensors +transformers diff --git a/examples/weight-custody-manifest/requirements-lora.txt b/examples/weight-custody-manifest/requirements-lora.txt new file mode 100644 index 00000000..65490dcd --- /dev/null +++ b/examples/weight-custody-manifest/requirements-lora.txt @@ -0,0 +1,7 @@ +huggingface_hub==1.33.0 +safetensors==0.8.0 +transformers==5.17.0 +peft==0.21.0 +accelerate==1.15.0 +torch==2.14.0 +model-signing==1.1.1 diff --git a/examples/weight-custody-manifest/requirements.txt b/examples/weight-custody-manifest/requirements.txt new file mode 100644 index 00000000..0f63321f --- /dev/null +++ b/examples/weight-custody-manifest/requirements.txt @@ -0,0 +1,4 @@ +# The Weight Custody Manifest reference SDK, from PyPI. +# Covers the offline examples. provenance_model_signing.py additionally needs the +# [model-signing] extra: pip install "weight-custody-manifest[model-signing]". +weight-custody-manifest>=0.28.4 diff --git a/examples/weight-custody-manifest/revocation_kill_switch.py b/examples/weight-custody-manifest/revocation_kill_switch.py new file mode 100644 index 00000000..dbc98c63 --- /dev/null +++ b/examples/weight-custody-manifest/revocation_kill_switch.py @@ -0,0 +1,144 @@ +"""The kill switch: revocation blocks new releases, wipe-on-lapse stops the current one. + + python revocation_kill_switch.py + +Real WCM code, no hardware. A recall (a safety issue, a license breach, a +compromised key) has to actually stop a model that is already serving. WCM does +that from two sides at once: + + - the KBS refuses any NEW release once the attestation key / manifest is + revoked (checked live per release); + - the enclave already holding a key zeroizes it at the next cadence lapse + unless it re-attests, and re-attestation now fails because of the revocation. + +So the worst case is one cadence window, even against a host that swallows every +push notification. +""" +from __future__ import annotations + +import hashlib +from datetime import datetime, timedelta, timezone + +from wcm import ( + Ed25519Signer, + EnclaveSession, + generate_ed25519, + KeyBrokerService, + KeyWipedError, + manifest_identity, + SoftwareProvider, + WeightCustodyManifest, +) + +from _mock_attestation import waive_mock_verification + +NOW = datetime(2026, 1, 1, tzinfo=timezone.utc) +LIVE_KEY_ID = "vcek:live-0001" + + +def rule(title: str) -> None: + print(f"\n{'=' * 70}\n{title}\n{'=' * 70}") + + +def sha256(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def build_manifest(weights_hash: str, serving: str): + doc = { + "manifest_version": "0.1", + "weights_hash": weights_hash, + "builder": {"identity": "lab", "signing_key": "ed25519:lab"}, + "release_terms": { + "license": "deployment-agreement", + "permitted_derivatives": "none", + "permitted_environments": ["attested-enclave"], + }, + "release_policy": { + "required_assurance_tier": "hardware-attested", + "trusted_time_source": "secure-tsc", + "required_hw_platform": ["amd-sev-snp"], + "required_serving_image": { + "signer": "ed25519:lab", + "release_rule": "prefer-current", + "accepted_measurements": [{"measurement": serving, "status": "current"}], + }, + # Revocation is checked live on every release. + "attestation_revocation_check": "live-per-release, max-cache-age: short-window", + "revocation_authority": "builder-and-opaque-joint", + }, + "custody": { + "custodian": "customer", + "custodian_type": "customer-self-custody", + "kbs_image": {"measurement": sha256(b"kbs image"), "signer": "ed25519:lab"}, + "enclave_id": "did:example:enclave", + "attestation_cadence": "30m", + }, + } + b, c = generate_ed25519(), generate_ed25519() + m = WeightCustodyManifest.model_validate(waive_mock_verification(doc)) + return m.with_signatures([ + Ed25519Signer(b).sign(m.unsigned_dict(), role="builder", signer="lab"), + Ed25519Signer(c).sign(m.unsigned_dict(), role="custodian", signer="customer"), + ]) + + +def _release(kbs, manifest, serving): + ch = kbs.issue_challenge() + ev = SoftwareProvider().produce( + ch, serving_image_measurement=serving, gpu_measurement="nvidia-rim:golden", + attestation_key_id=LIVE_KEY_ID, + ) + return kbs.verify_and_release(manifest, ev) + + +def main() -> int: + serving = sha256(b"measured serving stack") + weights_hash = sha256(b"the model weights") + manifest = build_manifest(weights_hash, serving) + key = b"the-weight-decryption-key" + + rule("Step 1 - The model is attested, released, and serving") + kbs = KeyBrokerService( + {weights_hash: key}, + now=lambda: NOW, + # weight-custody-manifest 0.27.0 refuses to release unless the manifest's + # identity is pinned out of band. Without it, a caller could present an + # attacker-authored policy that reused a weights hash the broker already + # held and be released against terms nobody agreed. + trusted_manifest_identities=[manifest_identity(manifest)], + ) + decision = _release(kbs, manifest, serving) + print("released :", decision.released) + session = EnclaveSession.from_release(manifest, decision, now=lambda: NOW) + session.use_key(now=NOW) + print("serving under a", "30m", "cadence") + + rule("Step 2 - A recall revokes the attestation key. New releases are refused.") + # The revocation authority marks the key revoked; the KBS enforces it live. + revoked_kbs = KeyBrokerService( + {weights_hash: key}, now=lambda: NOW, revoked_attestation_keys={LIVE_KEY_ID} + ) + denied = _release(revoked_kbs, manifest, serving) + print("new release after recall:", denied.released, " (refused)") + reason = next((c.detail for c in denied.failures if c.name == "attestation_revocation"), None) + print("why :", reason) + + rule("Step 3 - The enclave already serving zeroizes at the next cadence lapse") + later = NOW + timedelta(minutes=31) # past the 30m window, and re-attestation now fails + try: + session.use_key(now=later) + print("still serving : True (should not happen)") + except KeyWipedError: + print("cadence lapsed : key ZEROIZED (re-attestation would be refused anyway)") + print("state :", session.state.value) + + print() + print("Two sides of one kill switch: revocation stops the next release, wipe-on-lapse") + print("stops the running one. Anchoring the revocation in the transparency log") + print("(see transparency_log.py) makes it non-repudiable.") + return 0 if (decision.released and not denied.released and session.is_wiped) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/weight-custody-manifest/run_demos.py b/examples/weight-custody-manifest/run_demos.py new file mode 100644 index 00000000..1b830c62 --- /dev/null +++ b/examples/weight-custody-manifest/run_demos.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +"""Run every offline WCM demo and the unit tests, and report what broke. + +This exists because of a specific failure. weight-custody-manifest 0.27.0 +shipped two correct security changes, and both broke demos in this directory: +key release began refusing manifests whose identity was not pinned out of band, +and the memory-fingerprint challenge began requiring a signed sweep. Six demos +that passed on 0.26.0 failed on 0.27.0, and nothing caught it, because the SDK +repository had no way to run these and this repository only tested against +whatever was already published. + +So the list of what to run lives here, next to the demos, and both repositories +call it: this repository's CI on every PR, and the SDK's CI against a wheel +built from the branch under review. A change that breaks these now fails on the +pull request that causes it rather than after a release is on PyPI. + +**Demos are discovered, not listed.** Every top-level module that is not a test +and not explicitly excluded gets run. A new demo is covered the day it lands, +which is the point: the gap was a change nobody happened to exercise. A demo +that genuinely cannot run offline has to be added to ``REQUIRES_NETWORK`` with a +reason, which is a visible decision rather than a silent omission. + +Usage:: + + python run_demos.py # demos, then pytest + python run_demos.py --list # show what would run, and what is skipped +""" + +from __future__ import annotations + +import argparse +import pathlib +import subprocess +import sys +import time + +HERE = pathlib.Path(__file__).resolve().parent + +#: Scripts that cannot run in a bare CI container, and why. Anything here is +#: reported as skipped rather than quietly dropped, so the exclusions stay +#: visible every run instead of living in a comment nobody re-reads. +REQUIRES_NETWORK = { + "real_open_model.py": "downloads a real model from the Hugging Face Hub", + "real_lora_custody.py": "trains a LoRA adapter; needs torch and a download", +} + +#: This file. +SELF = pathlib.Path(__file__).name + + +def demos() -> list[pathlib.Path]: + """Every runnable demo, sorted, excluding tests, shared helpers and this runner.""" + return sorted( + path + for path in HERE.glob("*.py") + if path.name != SELF + and not path.name.startswith(("test_", "_")) + and path.name not in REQUIRES_NETWORK + ) + + +def run(command: list[str], label: str) -> tuple[bool, float, str]: + started = time.perf_counter() + completed = subprocess.run( + command, cwd=HERE, capture_output=True, text=True, encoding="utf-8", errors="replace" + ) + elapsed = time.perf_counter() - started + # Both streams: a demo that prints its refusal to stdout and traces to + # stderr is the normal shape here, and reading only one of them has sent + # people looking in the wrong place. + output = (completed.stdout or "") + (completed.stderr or "") + return completed.returncode == 0, elapsed, output + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--list", action="store_true", help="print the plan and exit") + parser.add_argument("--no-tests", action="store_true", help="skip the pytest run") + args = parser.parse_args(argv) + + scripts = demos() + + if args.list: + print(f"{len(scripts)} demo(s) would run:") + for path in scripts: + print(f" {path.name}") + print(f"\n{len(REQUIRES_NETWORK)} skipped:") + for name, why in sorted(REQUIRES_NETWORK.items()): + print(f" {name}: {why}") + return 0 + + try: + import wcm + + print(f"weight-custody-manifest {wcm.__version__}\n") + except ImportError: + print("weight-custody-manifest is not installed", file=sys.stderr) + return 1 + + failures: list[tuple[str, str]] = [] + + for path in scripts: + ok, elapsed, output = run([sys.executable, path.name], path.name) + print(f"{'ok ' if ok else 'FAIL'} {path.name:<32} {elapsed:5.1f}s") + if not ok: + failures.append((path.name, output)) + + for name, why in sorted(REQUIRES_NETWORK.items()): + print(f"skip {name:<32} {why}") + + if not args.no_tests: + ok, elapsed, output = run( + [sys.executable, "-m", "pytest", ".", "-q"], "pytest" + ) + print(f"{'ok ' if ok else 'FAIL'} {'unit tests':<32} {elapsed:5.1f}s") + if not ok: + failures.append(("unit tests", output)) + + if not failures: + print(f"\nall {len(scripts)} demos and the unit tests pass") + return 0 + + # The output is printed after the summary, not interleaved, so the shape of + # the failure is visible before the detail. Six demos failing the same way + # is a different problem from one failing on its own, and that is the first + # thing worth knowing. + print(f"\n{len(failures)} failure(s): {', '.join(name for name, _ in failures)}\n") + for name, output in failures: + print("=" * 72) + print(name) + print("=" * 72) + print(output.strip()[-4000:]) + print() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/weight-custody-manifest/snp_replay.py b/examples/weight-custody-manifest/snp_replay.py new file mode 100644 index 00000000..f96f6690 --- /dev/null +++ b/examples/weight-custody-manifest/snp_replay.py @@ -0,0 +1,160 @@ +"""Replay a captured AMD SEV-SNP quote through the full WCM verification path. + + python snp_replay.py # the committed synthetic bundle + python snp_replay.py path/to/snp_quote.json # a real captured bundle + +This is the CPU half of Layer 2's composite verification (SPEC 3.2), run OFFLINE +against a recorded quote. It exercises exactly the code a KBS runs before +releasing a key: parse the SNP report, verify the VCEK -> ASK -> ARK certificate +chain to a trusted AMD root, verify the report's own signature under the VCEK, +and confirm REPORT_DATA binds the challenge nonce (anti-replay). + +A "bundle" is the evidence a KBS would receive, captured to a JSON file so the +verification is reproducible with no hardware: + + { "kind": "wcm-snp-quote-bundle/v1", "source": "...", + "report_b64": "...", # raw SNP report bytes, base64 + "vcek_pem": "...", # leaf cert that signed the report + "intermediates_pem": ["..."], # ASK (empty for the synthetic root) + "root_pem": "...", # trusted root (real AMD ARK, or synthetic) + "expected_nonce": "hex|null", # guest-controlled REPORT_DATA nonce, or null + "expected_measurement": "hex" } # optional launch measurement to assert + +Two freshness topologies, and the demo handles both honestly: + + * Guest-controlled REPORT_DATA (bare-metal /dev/sev-guest, and the synthetic + bundle): the guest writes sha256(nonce) into REPORT_DATA, so the KBS's nonce + gate applies directly. `expected_nonce` is set. + * Azure CVM vTPM path: REPORT_DATA is paravisor-bound to the vTPM attestation + key, NOT to our KBS nonce, so freshness comes from a separate vTPM quote over + the AK (one layer up). `expected_nonce` is null; the demo verifies the chain + and report signature (which ARE genuine on Azure) and reports the AK binding + rather than pretending the SNP report itself binds our nonce. + +The committed default bundle is `source: synthetic` (a self-consistent stand-in +chain, NOT real silicon) so the demo runs anywhere. Capture a genuine one on an +Azure SEV-SNP CVM with tools/capture_snp_quote.py. +""" +from __future__ import annotations + +import base64 +import datetime +import json +import pathlib +import sys + +from cryptography import x509 + +from wcm import ( + QuoteVerifier, + SnpQuoteParser, + TrustStore, + parse_snp_report, + verify_cert_chain, + verify_snp_report_signature, +) + +DEFAULT_BUNDLE = pathlib.Path(__file__).resolve().parent / "fixtures" / "snp_quote_synthetic.json" + + +def rule(title: str) -> None: + print(f"\n{'=' * 70}\n{title}\n{'=' * 70}") + + +def load_bundle(path: pathlib.Path) -> dict: + bundle = json.loads(path.read_text(encoding="utf-8")) + if bundle.get("kind") != "wcm-snp-quote-bundle/v1": + raise SystemExit(f"{path}: not a wcm-snp-quote-bundle/v1") + return bundle + + +def main() -> int: + path = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_BUNDLE + bundle = load_bundle(path) + + rule(f"SEV-SNP quote replay (source: {bundle['source']})") + print("bundle :", path) + if bundle["source"] == "synthetic": + print("WARNING : synthetic bundle, NOT real hardware. This proves the") + print(" verification path, not a genuine attestation. Capture a real") + print(" one with tools/capture_snp_quote.py on an Azure SEV-SNP CVM.") + else: + print("this is a genuine hardware quote captured from real SEV-SNP silicon.") + + vcek = x509.load_pem_x509_certificate(bundle["vcek_pem"].encode()) + intermediates = [x509.load_pem_x509_certificate(p.encode()) for p in bundle["intermediates_pem"]] + root = x509.load_pem_x509_certificate(bundle["root_pem"].encode()) + report_b64 = bundle["report_b64"] + nonce = bundle["expected_nonce"] + + trust = TrustStore() + trust.add_root(root) + report = base64.b64decode(report_b64) + + if nonce: + # Guest-controlled REPORT_DATA: the KBS's full gate applies, nonce and all. + rule("Step 1 - The gate the KBS runs (parse + chain + signature + nonce)") + result = QuoteVerifier(SnpQuoteParser(vcek, intermediates), trust).verify( + report_b64, expected_nonce=nonce + ) + print("verified :", result.verified) + if not result.verified: + print("reason :", result.reason) + print("\nThe KBS would REFUSE to release the key.") + return 1 + print("leaf (VCEK) subject :", result.leaf_subject) + print("checks passed : VCEK chains to the trusted root; report signature") + print(" verifies under the VCEK; REPORT_DATA binds the nonce") + print(f" {nonce[:16]}... (a captured quote for a different") + print(" nonce would be rejected as a replay).") + else: + # Azure vTPM path: REPORT_DATA binds the vTPM AK, not our nonce. Verify the + # parts that are genuine on this platform (chain + report signature) and be + # explicit that freshness lives one layer up, in the vTPM quote over the AK. + rule("Step 1 - Chain + report signature (Azure vTPM freshness topology)") + chain_error = verify_cert_chain(vcek, intermediates, trust, datetime.datetime.now(datetime.timezone.utc)) + print("VCEK chains to root :", chain_error is None, "" if chain_error is None else f"({chain_error})") + sig_ok = verify_snp_report_signature(report, vcek) + print("report signature ok :", sig_ok) + if chain_error is not None or not sig_ok: + print("\nThe KBS would REFUSE to release the key.") + return 1 + rd = parse_snp_report(report).report_data + print("REPORT_DATA :", rd.hex()[:32], "...") + print("note : on Azure CVMs REPORT_DATA is paravisor-bound to the") + print(" vTPM AK, not our KBS nonce. Freshness comes from a") + print(" separate vTPM quote over that AK (SPEC 3.2). This") + print(" demo replays the SNP report; the vTPM-quote layer is") + print(" not part of the recorded bundle.") + + # Surface the measured fields the manifest's policy would pin. + rule("Step 2 - Measured fields the manifest pins") + parsed = parse_snp_report(report) + print("SNP report version :", parsed.version) + print("launch measurement :", parsed.measurement.hex()) + print("chip id (VCEK) :", parsed.chip_id.hex()[:32], "...") + expected_m = bundle.get("expected_measurement") + if expected_m: + ok = parsed.measurement.hex() == expected_m + print("measurement matches manifest expectation:", ok) + if not ok: + print(" -> a KBS pinning required launch measurement would refuse here") + return 1 + + rule("What this did and did not prove") + if nonce: + print("proved : the SEV-SNP CPU quote is genuine, unmodified, bound to our") + print(" nonce, and rooted in the trusted AMD chain (Layer 2 CPU half).") + else: + print("proved : the SEV-SNP CPU quote is genuine and unmodified, signed by a") + print(" VCEK rooted in the trusted AMD chain, on real silicon (Layer 2") + print(" CPU half). Freshness is the vTPM-quote layer's job, not shown here.") + print("not here : the separate GPU (NVIDIA CC) report and the CPU-GPU nonce") + print(" binding (SPEC 3.2 composite verification); GPU needs H100 quota.") + if bundle["source"] == "synthetic": + print("not here : real silicon. Swap in a captured bundle for that.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/weight-custody-manifest/sovereign_self_custody.py b/examples/weight-custody-manifest/sovereign_self_custody.py new file mode 100644 index 00000000..a99dc5cf --- /dev/null +++ b/examples/weight-custody-manifest/sovereign_self_custody.py @@ -0,0 +1,220 @@ +"""Sovereign self-custody with threshold split-key release (SPEC 3.5, decision 15). + + python sovereign_self_custody.py + +Everything here is real WCM code with a software (mock) attestation provider, so +it runs anywhere with no hardware. + +The problem this solves +----------------------- +A sovereign runs the model in its own jurisdiction, on hardware it owns, and +operates the key release service (KBS) itself. That is the hostile-owner +posture: the owner can forge its own KBS attestation quote (open question 8.8, +key-extraction half). Single-key self-custody is therefore unsound for a +sovereign, because one forged quote would self-release the key. + +Decision 15's answer is threshold split-key release: the key is split so that no +single party, the sovereign included, holds it. The attested KBS enclave +reconstructs the key only from a quorum of shares contributed by independent +parties. One forged quote is then not enough, because it still yields only one +share. This demo shows that end to end with a 2-of-3 split across the builder, +the sovereign, and the custodian. + +What is real vs modelled here: the split, the reconstruction, the attestation +gate, and the manifest's sovereign quorum are all real SDK code. The share +contribution "into the attested enclave" is narrated, not sandboxed; a real +deployment runs the combine inside the measured KBS enclave so no party sees the +assembled key. +""" +from __future__ import annotations + +import hashlib +import os + +from wcm import ( + BytearrayMemoryRange, + combine_shares, + Ed25519Signer, + generate_ed25519, + KeyBrokerService, + manifest_identity, + memory_sweep_public_key, + run_memory_sweep, + SoftwareProvider, + split_secret, + VerificationContext, + verify_manifest, + WeightCustodyManifest, +) + +from _mock_attestation import waive_mock_verification + + +def rule(title: str) -> None: + print(f"\n{'=' * 70}\n{title}\n{'=' * 70}") + + +def sha256(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def main() -> int: + # Three independent parties. In a sovereign deployment none of them may hold + # the whole key on its own. + builder_kp, custodian_kp, sovereign_kp = ( + generate_ed25519(), + generate_ed25519(), + generate_ed25519(), + ) + serving = sha256(b"builder-signed serving stack, measured") + weights_hash = sha256(b"the frontier weights") + + # ---- Step 0: the sovereign self-custody manifest ------------------------ + rule("Step 0 - Sovereign manifest (quorum revocation, self-custody, hardened)") + doc = { + "manifest_version": "0.1", + "weights_hash": weights_hash, + "builder": {"identity": "frontier-lab", "signing_key": "ed25519:builder"}, + "release_terms": { + "license": "sovereign-deployment-agreement", + "permitted_derivatives": "none", + "permitted_environments": ["sovereign-attested-enclave"], + }, + "release_policy": { + "required_assurance_tier": "hardware-attested", + # Hostile-owner posture: physical hardening and the memory-fingerprint + # challenge are mandatory here (SPEC 3.5, 3.6). + "physical_hardening": "tamper-evident-enclosure+access-control+chain-of-custody", + "memory_fingerprint_challenge": "required-for-hostile-owner-posture", + "trusted_time_source": "secure-tsc", + "required_hw_platform": ["amd-sev-snp", "nvidia-cc-gpu"], + "required_gpu_measurement": {"rim_pin": "nvidia-rim:demo-golden"}, + "required_serving_image": { + "signer": "ed25519:builder", + "release_rule": "prefer-current", + "accepted_measurements": [{"measurement": serving, "status": "current"}], + }, + "attestation_revocation_check": "live-per-release, max-cache-age: short-window", + # Sovereign profile: revocation is quorum-only, no unilateral kill. + "revocation_authority": "quorum", + "sovereign_profile": { + "enabled": True, + "revocation_authority": "quorum", + "sovereign_signer": "sovereign-security-team", + }, + }, + "custody": { + "custodian": "sovereign-national-ai", + "custodian_type": "customer-self-custody", + "kbs_image": {"measurement": sha256(b"builder-signed KBS image"), "signer": "ed25519:builder"}, + "enclave_id": "did:sovereign:kbs-enclave-01", + "attestation_cadence": "1h", + }, + } + manifest = WeightCustodyManifest.model_validate(waive_mock_verification(doc)) + manifest = manifest.with_signatures([ + Ed25519Signer(builder_kp).sign(manifest.unsigned_dict(), role="builder", signer="frontier-lab"), + Ed25519Signer(custodian_kp).sign(manifest.unsigned_dict(), role="custodian", signer="sovereign-national-ai"), + Ed25519Signer(sovereign_kp).sign(manifest.unsigned_dict(), role="sovereign", signer="sovereign-security-team"), + ]) + print("sovereign_profile.enabled :", manifest.release_policy.sovereign_profile.enabled) + print("custodian_type :", manifest.custody.custodian_type.value) + print("signed by : builder + custodian + sovereign (quorum)") + + # ---- Step 1: verify the manifest (the sovereign quorum must be present) -- + rule("Step 1 - Verify the manifest (sovereign quorum required)") + ctx = VerificationContext() + for kp in (builder_kp, custodian_kp, sovereign_kp): + ctx.add_key(kp.public_bytes) + result = verify_manifest(manifest, ctx) + print("manifest valid :", result.ok) + if not result.ok: + print("errors:", result.errors, "missing:", result.missing_roles) + return 1 + + # ---- Step 2: split the key so no single party holds it ------------------ + rule("Step 2 - Split the decryption key 2-of-3 (builder, sovereign, custodian)") + key = hashlib.sha256(b"the weights decryption key").digest() + shares = split_secret(key, threshold=2, shares=3) + builder_share, sovereign_share, custodian_share = shares + print("key split into : 3 shares, any 2 reconstruct") + print("no single party holds the key. Each holds one share.") + + # ---- Step 3: the attested self-custody KBS authorizes release ----------- + rule("Step 3 - Attested KBS gate (parties verify the enclave before sharing)") + # The self-custody KBS holds NO weight key (empty entry): the gate proves the + # enclave is the builder-measured release coordinator; the key comes from the + # quorum, not from the KBS. + # The hostile-owner posture requires a memory-fingerprint challenge, and since + # weight-custody-manifest 0.27.0 that means a SIGNED sweep: the runtime writes + # unpredictable nonce-derived data across its declared range, reads it back in + # a different nonce-derived order, and signs the transcript. The broker needs + # the sweep key to check it. An unsigned fingerprint is refused rather than + # accepted on trust, which is the point: an aliasing attack that can fake the + # readback can also fake an unsigned claim about it. + sweep_key = generate_ed25519().private_key + + kbs = KeyBrokerService( + {weights_hash: b""}, + # 0.27.0 also refuses to release unless the manifest's identity is pinned + # out of band. Without it, a caller could present an attacker-authored + # policy that reused a weights hash the broker already held and be + # released against terms nobody agreed. + trusted_manifest_identities=[manifest_identity(manifest)], + memory_fingerprint_public_key_b64url=memory_sweep_public_key(sweep_key), + ) + challenge = kbs.issue_challenge() + evidence = SoftwareProvider().produce( + challenge, + serving_image_measurement=serving, + gpu_measurement="nvidia-rim:demo-golden", + ) + evidence = evidence.model_copy( + update={ + "memory_fingerprint": run_memory_sweep( + # A stand-in for the enclave's real DRAM range. The algorithm is + # the same; what a bytearray cannot do is prove anything about + # physical memory, which is why WCM issue #79 is still open. + BytearrayMemoryRange(64 * 4096), + challenge_nonce=challenge.nonce, + signing_key=sweep_key, + sweep_secret=os.urandom(32), + ) + } + ) + decision = kbs.verify_and_release(manifest, evidence) + print("attestation gate passed :", decision.released) + if not decision.released: + for c in decision.failures: + print(" failed:", c.name, "-", c.detail) + return 1 + print("each party checks this attestation before contributing its share.") + + # ---- Step 4: a quorum reconstructs the key inside the enclave ----------- + rule("Step 4 - Quorum contributes shares; enclave reconstructs the key") + reconstructed = combine_shares([builder_share, sovereign_share]) + print("builder + sovereign -> key matches :", reconstructed == key) + # Any two shares work (fault tolerance): losing one party does not lose the key. + print("builder + custodian -> key matches :", combine_shares([builder_share, custodian_share]) == key) + + # ---- Step 5: one party (and one forged quote) is not enough ------------- + rule("Step 5 - The decision-15 property: a single forged quote cannot release") + sovereign_alone = combine_shares([sovereign_share]) + print("sovereign's single share -> key :", sovereign_alone == key, " (cannot reconstruct)") + print("even if the sovereign forges its own KBS quote (open 8.8), it still holds") + print("only one share, so one forged quote does not assemble the key. It needs a") + print("quorum that includes an independent party. That is why threshold is a") + print("PREREQUISITE for sovereign self-custody, not an optional hardening.") + + # ---- Step 6: what carries the guarantee here ---------------------------- + rule("What carries the guarantee (and what does not)") + print("carries it : threshold (no single party assembles the key), the sovereign") + print(" revocation quorum, mandatory physical hardening, transparency log.") + print("does NOT : wipe-on-lapse is not an independent floor against a hardware") + print(" owner who can forge attestation (SPEC 3.5). The bound is the") + print(" quorum and the hardening, not enclave-resident timing.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/weight-custody-manifest/test_real_lora_custody.py b/examples/weight-custody-manifest/test_real_lora_custody.py new file mode 100644 index 00000000..40f3b49c --- /dev/null +++ b/examples/weight-custody-manifest/test_real_lora_custody.py @@ -0,0 +1,75 @@ +import base64 +import pathlib + +import pytest +from cryptography.exceptions import InvalidTag + +import real_lora_custody +from real_lora_custody import decrypt_artifact, encrypt_artifact, sign_artifact + + +def _adapter(path: pathlib.Path) -> pathlib.Path: + path.mkdir() + (path / "adapter_config.json").write_text('{"peft_type":"LORA"}', encoding="utf-8") + (path / "adapter_model.safetensors").write_bytes(b"real-saved-adapter-placeholder") + return path + + +def test_encrypted_adapter_round_trip(tmp_path: pathlib.Path) -> None: + source = _adapter(tmp_path / "source") + key = bytes(range(32)) + envelope = encrypt_artifact(source, key) + observed = decrypt_artifact(envelope, key, tmp_path / "verified") + assert observed == envelope["artifact_digest"] + assert (tmp_path / "verified" / "adapter_model.safetensors").read_bytes() == ( + b"real-saved-adapter-placeholder" + ) + + +def test_ciphertext_tamper_is_refused(tmp_path: pathlib.Path) -> None: + envelope = encrypt_artifact(_adapter(tmp_path / "source"), bytes(32)) + ciphertext = bytearray(base64.b64decode(envelope["ciphertext_b64"])) + ciphertext[0] ^= 1 + envelope["ciphertext_b64"] = base64.b64encode(ciphertext).decode() + with pytest.raises(InvalidTag): + decrypt_artifact(envelope, bytes(32), tmp_path / "refused") + + +def test_manifest_digest_substitution_is_refused(tmp_path: pathlib.Path) -> None: + envelope = encrypt_artifact(_adapter(tmp_path / "source"), bytes(32)) + envelope["artifact_digest"] = "sha256:" + "0" * 64 + with pytest.raises(InvalidTag): + decrypt_artifact(envelope, bytes(32), tmp_path / "refused") + + +def test_plaintext_staging_is_removed_when_encryption_fails( + tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = _adapter(tmp_path / "source") + + def fail(_root: pathlib.Path, _key: bytes) -> dict: + raise RuntimeError("synthetic encryption failure") + + monkeypatch.setattr(real_lora_custody, "encrypt_artifact", fail) + with pytest.raises(RuntimeError, match="synthetic encryption failure"): + real_lora_custody.encrypt_and_remove_staging(source, bytes(32)) + assert not source.exists() + + +def test_openssf_signature_covers_complete_adapter(tmp_path: pathlib.Path) -> None: + source = _adapter(tmp_path / "source") + digest, signature, public_key = sign_artifact(source, tmp_path) + assert digest.startswith("sha256:") + assert signature.is_file() + assert public_key.is_file() + + from model_signing import verifying + + verifying.Config().use_elliptic_key_verifier(public_key=public_key).verify( + source, signature + ) + (source / "adapter_model.safetensors").write_bytes(b"tampered") + with pytest.raises(Exception): + verifying.Config().use_elliptic_key_verifier(public_key=public_key).verify( + source, signature + ) diff --git a/examples/weight-custody-manifest/test_real_open_model_artifact.py b/examples/weight-custody-manifest/test_real_open_model_artifact.py new file mode 100644 index 00000000..3a0869a4 --- /dev/null +++ b/examples/weight-custody-manifest/test_real_open_model_artifact.py @@ -0,0 +1,67 @@ +"""Offline checks for deterministic complete-snapshot hashing.""" +from __future__ import annotations + +import pathlib + +from wcm import artifact_files + +from real_open_model import sha256_artifact, tampered_digest + + +def test_artifact_hash_is_order_independent_and_covers_all_files(tmp_path: pathlib.Path) -> None: + (tmp_path / "config.json").write_text("{}", encoding="utf-8") + (tmp_path / "model-00002-of-00002.safetensors").write_bytes(b"second") + (tmp_path / "model-00001-of-00002.safetensors").write_bytes(b"first") + + before = sha256_artifact(tmp_path) + assert [p.name for p in artifact_files(tmp_path, follow_symlinks=True)] == [ + "config.json", + "model-00001-of-00002.safetensors", + "model-00002-of-00002.safetensors", + ] + + (tmp_path / "model-00002-of-00002.safetensors").write_bytes(b"SECOND") + assert sha256_artifact(tmp_path) != before + + +def test_cache_metadata_is_not_part_of_serving_artifact(tmp_path: pathlib.Path) -> None: + (tmp_path / "model.safetensors").write_bytes(b"weights") + cache = tmp_path / ".cache" / "huggingface" + cache.mkdir(parents=True) + (cache / "download-metadata.json").write_text("one", encoding="utf-8") + + digest = sha256_artifact(tmp_path) + (cache / "download-metadata.json").write_text("two", encoding="utf-8") + assert sha256_artifact(tmp_path) == digest + + +def test_tampered_fork_hashes_differently_and_leaves_the_original_alone( + tmp_path: pathlib.Path, +) -> None: + """The demo must never write to the model somebody just downloaded. + + This used to be a flip_first_byte flag threaded through the hash function. + That avoided the copy but meant a second hashing path existing only to fake + tampering; the recipe now comes from the SDK and the fork is a real one. + """ + artifact = tmp_path / "model.safetensors" + artifact.write_bytes(b"weights") + original = artifact.read_bytes() + + assert tampered_digest(artifact) != sha256_artifact(artifact) + assert artifact.read_bytes() == original + + +def test_tampered_fork_of_a_directory_also_differs(tmp_path: pathlib.Path) -> None: + (tmp_path / "model.safetensors").write_bytes(b"weights") + (tmp_path / "config.json").write_text("{}", encoding="utf-8") + + assert tampered_digest(tmp_path) != sha256_artifact(tmp_path) + + +def test_the_recipe_comes_from_the_sdk() -> None: + """One implementation. It lived here and in two integrations until 0.27.0.""" + import wcm + + assert artifact_files is wcm.artifact_files + assert wcm.ARTIFACT_DIGEST_RECIPE == "wcm-artifact-digest/v1" diff --git a/examples/weight-custody-manifest/transparency_log.py b/examples/weight-custody-manifest/transparency_log.py new file mode 100644 index 00000000..9e1d0130 --- /dev/null +++ b/examples/weight-custody-manifest/transparency_log.py @@ -0,0 +1,83 @@ +"""Transparency log: inclusion, consistency, and detecting a suppressed revocation. + + python transparency_log.py + +Real WCM code, no hardware. WCM anchors manifests and revocations in an +append-only Merkle log (RFC 9162), so an operator cannot quietly serve a revoked +model or fork the history. This shows the three checks a monitor relies on: +an inclusion proof (an entry really is in the log), a consistency proof (the log +only ever appended, never rewrote), and a signed tree head (the log operator +actually vouched for this state). Then it shows the point of it all: a revocation +that is in the log cannot be hidden, and a forked log fails consistency. +""" +from __future__ import annotations + +import hashlib + +from wcm import ( + EntryType, + TransparencyLog, + generate_ed25519, + verify_inclusion, + verify_log_consistency, + verify_sth, +) + + +def rule(title: str) -> None: + print(f"\n{'=' * 70}\n{title}\n{'=' * 70}") + + +def sha256(data: bytes) -> str: + return "sha256:" + hashlib.sha256(data).hexdigest() + + +def main() -> int: + log_kp = generate_ed25519() + log = TransparencyLog(log_kp) + + rule("Step 1 - Append two manifests; take a signed tree head") + m1 = {"weights_hash": sha256(b"model-1"), "status": "in-force"} + m2 = {"weights_hash": sha256(b"model-2"), "status": "in-force"} + i1 = log.append(m1, entry_type=EntryType.manifest) + i2 = log.append(m2, entry_type=EntryType.manifest) + size1 = log.size + sth1 = log.signed_tree_head() + print("appended at indices :", i1, i2, " log size:", log.size) + print("STH signature valid :", verify_sth(sth1, log_kp.public_bytes)) + + rule("Step 2 - Inclusion proof: manifest 1 really is in the log") + proof = log.inclusion_proof(i1) + print("inclusion of m1 verifies:", verify_inclusion(m1, EntryType.manifest, proof, sth1)) + # A statement that was never logged does not verify against the same proof slot. + forged = {"weights_hash": sha256(b"never-logged"), "status": "in-force"} + print("inclusion of a forgery :", verify_inclusion(forged, EntryType.manifest, proof, sth1)) + + rule("Step 3 - Revoke model 1; the log only grows") + rev = {"weights_hash": m1["weights_hash"], "reason": "safety-recall"} + i3 = log.append(rev, entry_type=EntryType.revocation) + sth2 = log.signed_tree_head() + print("revocation appended at :", i3, " new size:", log.size) + + rule("Step 4 - Consistency proof: the new log is an append-only extension") + cproof = log.consistency_proof(size1) + print("old->new is consistent :", verify_log_consistency(sth1, sth2, cproof)) + + rule("Step 5 - A suppressed revocation is detectable") + # The operator would love to serve model 1 as if it were never revoked. But the + # revocation is in the append-only log, and a monitor can find it. + found_at = log.find(rev, entry_type=EntryType.revocation) + print("monitor finds the revocation at index:", found_at, "(cannot be hidden)") + # And a revocation that a party CLAIMS but never logged simply is not there, + # so 'I revoked it' is only credible if it is in the transparency log. + unlogged = {"weights_hash": m2["weights_hash"], "reason": "claimed-but-not-logged"} + print("an unlogged revocation claim -> :", log.find(unlogged, entry_type=EntryType.revocation), + "(not in the log, so not provable)") + print() + print("A forked or rewritten log would fail the Step 4 consistency check, so the") + print("operator cannot both keep serving and pretend the revocation never happened.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())