diff --git a/.github/workflows/python-sdk.yml b/.github/workflows/python-sdk.yml new file mode 100644 index 00000000..18ee6a92 --- /dev/null +++ b/.github/workflows/python-sdk.yml @@ -0,0 +1,240 @@ +name: Python SDK package + +on: + pull_request: + paths: + - "bindings/python/**" + - "bindings/python-adapters/sqlite/**" + - "bindings/wasm/auths-proof-wasm/examples/generate-node-vectors.rs" + - ".github/workflows/python-sdk.yml" + - "Cargo.lock" + - "Cargo.toml" + push: + branches: [main] + paths: + - "bindings/python/**" + - "bindings/python-adapters/sqlite/**" + - "bindings/wasm/auths-proof-wasm/examples/generate-node-vectors.rs" + - ".github/workflows/python-sdk.yml" + - "Cargo.lock" + - "Cargo.toml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + source-contract: + name: source behavior and contracts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: ./.github/actions/setup-rust-cache + with: + toolchain: 1.97.1 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - name: Create source-test virtual environment + shell: bash + run: | + python -m venv .venv + echo "$PWD/.venv/bin" >> "$GITHUB_PATH" + - run: >- + python -m pip install maturin==1.9.6 pytest==9.0.2 + pytest-asyncio==1.3.0 ruff==0.14.9 mypy==1.19.1 pyright==1.1.407 + - run: >- + cargo run --locked -p auths-proof-wasm + --example generate-node-vectors -- target/binding-vectors + - run: maturin develop --manifest-path bindings/python/Cargo.toml + - run: python -m pip install --no-deps -e bindings/python-adapters/sqlite + - run: pytest -q bindings/python/tests + - run: pytest -q bindings/python-adapters/sqlite/tests + - run: >- + ruff check bindings/python/python bindings/python/tests + bindings/python/external bindings/python/typecheck + bindings/python-adapters/sqlite + - run: >- + python -m mypy --strict --warn-unused-ignores + bindings/python-adapters/sqlite/python/auths_sqlite + - run: >- + python -m pyright --pythonpath "$(command -v python)" + -p bindings/python-adapters/sqlite/pyrightconfig.json + - run: python bindings/python/tools/check_public_api.py + - run: python bindings/python/tools/check_contract.py + - run: python bindings/python/tools/check_doc_snippets.py + + consumer-contract: + name: build external-consumer contract + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: ./.github/actions/setup-rust-cache + with: + toolchain: 1.97.1 + - run: >- + cargo run --locked -p auths-proof-wasm + --example generate-node-vectors -- target/binding-vectors + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: python-sdk-consumer-${{ github.run_id }}-${{ github.run_attempt }} + path: | + target/binding-vectors/ + bindings/customer-journey-matrix-v1.json + bindings/python/api/public-api.txt + bindings/python/adapter-contracts.json + bindings/python/external/full_workflow_consumer.py + bindings/python/examples/identity_quickstart.py + bindings/python/identity-conformance-v1.json + bindings/python/native-abi-v2.json + bindings/python/performance-baseline.json + bindings/python/pyrightconfig.json + bindings/python/python/auths/ + bindings/python/tools/check_public_api.py + bindings/python/tools/check_contract.py + bindings/python/tools/check_doc_snippets.py + bindings/python/tools/check_performance.py + bindings/python/tools/check_wheel.py + bindings/python/sdk-capability.json + bindings/python/sdk-runtime-contract.json + bindings/python/typecheck/*.py + bindings/python/typecheck/installed-pyrightconfig.json + if-no-files-found: error + retention-days: 1 + compression-level: 9 + + wheel-build: + name: build abi3 wheel (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: ./.github/actions/setup-rust-cache + with: + toolchain: 1.97.1 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.9" + - run: python -m pip install maturin==1.9.6 + - run: >- + maturin build --release --locked + --manifest-path bindings/python/Cargo.toml + --out target/python-release-wheels + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: python-wheel-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }} + path: target/python-release-wheels/*.whl + if-no-files-found: error + retention-days: 1 + compression-level: 0 + + installed-workflow: + name: installed CPython ${{ matrix.python }} (${{ matrix.os }}) + needs: [consumer-contract, wheel-build] + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ matrix.python }} + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: python-wheel-${{ matrix.os }}-${{ github.run_id }}-${{ github.run_attempt }} + path: wheelhouse + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: python-sdk-consumer-${{ github.run_id }}-${{ github.run_attempt }} + path: consumer + - name: Assert a source-free consumer boundary + shell: bash + run: | + if find consumer -type f \( \ + -name '*.rs' -o \ + -name 'Cargo.toml' -o \ + -name 'Cargo.lock' -o \ + -name 'rust-toolchain*' \ + \) -print -quit | grep -q .; then + echo "::error::External wheel workflow received Rust build inputs." + exit 1 + fi + - name: Install only the built wheel + shell: bash + run: python -m pip install wheelhouse/*.whl + - name: Inspect exact wheel contents + shell: bash + run: python consumer/bindings/python/tools/check_wheel.py wheelhouse/*.whl + - name: Execute the external Full Workflow consumer + shell: bash + working-directory: ${{ runner.temp }} + run: >- + python "${{ github.workspace }}/consumer/bindings/python/external/full_workflow_consumer.py" + "${{ github.workspace }}/consumer/target/binding-vectors" + - name: Execute the installed identity quickstart + shell: bash + working-directory: ${{ runner.temp }} + run: >- + python "${{ github.workspace }}/consumer/bindings/python/examples/identity_quickstart.py" + - name: Check exact package/native contracts + shell: bash + run: python consumer/bindings/python/tools/check_contract.py + - name: Check installed-wheel performance contract + shell: bash + run: >- + python consumer/bindings/python/tools/check_performance.py + consumer/target/binding-vectors wheelhouse/*.whl + + typing-and-api: + name: installed typing and API contract + needs: [consumer-contract, wheel-build] + runs-on: ubuntu-latest + steps: + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.9" + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: python-wheel-ubuntu-latest-${{ github.run_id }}-${{ github.run_attempt }} + path: wheelhouse + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: python-sdk-consumer-${{ github.run_id }}-${{ github.run_attempt }} + path: consumer + - name: Install the wheel and pinned type checkers + shell: bash + run: >- + python -m pip install wheelhouse/*.whl + mypy==1.19.1 pyright==1.1.407 + - name: Check strict mypy source contract + run: >- + python -m mypy --strict --warn-unused-ignores + consumer/bindings/python/python/auths + - name: Check installed mypy consumer narrowing and negative boundaries + run: >- + python -m mypy --strict --warn-unused-ignores + consumer/bindings/python/typecheck/mcp_consumer.py + consumer/bindings/python/typecheck/elite_consumer.py + consumer/bindings/python/typecheck/workflow_consumer.py + consumer/bindings/python/typecheck/mypy_negative.py + - name: Check strict Pyright source contract + run: >- + python -m pyright --pythonpath "$(command -v python)" + -p consumer/bindings/python/pyrightconfig.json + - name: Check installed Pyright consumer narrowing and negative boundaries + run: >- + python -m pyright --pythonpath "$(command -v python)" + -p consumer/bindings/python/typecheck/installed-pyrightconfig.json + - name: Check the installed public API snapshot + run: python consumer/bindings/python/tools/check_public_api.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..b84decdb --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +minimum_pre_commit_version: "3.0.0" + +repos: + - repo: local + hooks: + - id: python-native-clippy-fix + name: Apply Python native binding Clippy fixes + entry: cargo clippy --fix --package auths-proof-python --all-targets --all-features --allow-dirty --allow-staged + language: system + files: ^bindings/python/(Cargo\.toml|src/.*\.rs)$ + pass_filenames: false + require_serial: true + + - id: rust-format + name: Apply Rust formatting + entry: cargo fmt --all + language: system + files: \.rs$ + pass_filenames: false + require_serial: true diff --git a/Cargo.lock b/Cargo.lock index 6d909ded..c2dfe22a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1143,8 +1143,30 @@ dependencies = [ name = "auths-proof-python" version = "1.0.0-rc.1" dependencies = [ - "auths-proof-wasm", + "auths-author", + "auths-codec", + "auths-custody", + "auths-did-keri", + "auths-did-key", + "auths-identity", + "auths-identity-raw-key", + "auths-lifecycle", + "auths-model", + "auths-ports", + "auths-profile-api", + "auths-profile-domains", + "auths-profile-mcp", + "auths-raw-key", + "auths-registries", + "auths-sdk", + "auths-signature", + "auths-signature-ed25519", + "auths-verifier", + "getrandom 0.3.4", "pyo3", + "serde_json", + "serde_json_canonicalizer", + "subtle", ] [[package]] diff --git a/architecture/dependency-graph.dot b/architecture/dependency-graph.dot index 498cda69..3658cd2e 100644 --- a/architecture/dependency-graph.dot +++ b/architecture/dependency-graph.dot @@ -389,7 +389,25 @@ digraph auths_architecture { "auths-proof-offline-example" -> "auths-raw-key" [label="normal"]; "auths-proof-offline-example" -> "auths-registries" [label="normal"]; "auths-proof-offline-example" -> "auths-signature" [label="normal"]; - "auths-proof-python" -> "auths-proof-wasm" [label="normal"]; + "auths-proof-python" -> "auths-author" [label="normal"]; + "auths-proof-python" -> "auths-codec" [label="normal"]; + "auths-proof-python" -> "auths-custody" [label="normal"]; + "auths-proof-python" -> "auths-did-keri" [label="normal"]; + "auths-proof-python" -> "auths-did-key" [label="normal"]; + "auths-proof-python" -> "auths-identity" [label="normal"]; + "auths-proof-python" -> "auths-identity-raw-key" [label="normal"]; + "auths-proof-python" -> "auths-lifecycle" [label="normal"]; + "auths-proof-python" -> "auths-model" [label="normal"]; + "auths-proof-python" -> "auths-ports" [label="normal"]; + "auths-proof-python" -> "auths-profile-api" [label="normal"]; + "auths-proof-python" -> "auths-profile-domains" [label="normal"]; + "auths-proof-python" -> "auths-profile-mcp" [label="normal"]; + "auths-proof-python" -> "auths-raw-key" [label="normal"]; + "auths-proof-python" -> "auths-registries" [label="normal"]; + "auths-proof-python" -> "auths-sdk" [label="normal"]; + "auths-proof-python" -> "auths-signature" [label="normal"]; + "auths-proof-python" -> "auths-signature-ed25519" [label="normal"]; + "auths-proof-python" -> "auths-verifier" [label="normal"]; "auths-proof-wasm" -> "auths-author" [label="normal"]; "auths-proof-wasm" -> "auths-codec" [label="normal"]; "auths-proof-wasm" -> "auths-did-keri" [label="normal"]; diff --git a/architecture/dependency-graph.json b/architecture/dependency-graph.json index 1cd48b19..9cf99440 100644 --- a/architecture/dependency-graph.json +++ b/architecture/dependency-graph.json @@ -7225,8 +7225,36 @@ { "source": "auths-proof-python", "source_layer": "bindings", - "target": "auths-proof-wasm", - "target_layer": "bindings", + "target": "auths-author", + "target_layer": "core", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": false, + "features": [ + "std" + ] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "auths-codec", + "target_layer": "core", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": false, + "features": [ + "std" + ] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "auths-custody", + "target_layer": "product", "scope": "internal", "kind": "normal", "target_condition": null, @@ -7234,6 +7262,228 @@ "default_features": true, "features": [] }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "auths-did-keri", + "target_layer": "core", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": false, + "features": [ + "std" + ] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "auths-did-key", + "target_layer": "core", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": false, + "features": [ + "std" + ] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "auths-identity", + "target_layer": "core", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": false, + "features": [] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "auths-identity-raw-key", + "target_layer": "core", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": true, + "features": [] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "auths-lifecycle", + "target_layer": "product", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": false, + "features": [ + "std" + ] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "auths-model", + "target_layer": "core", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": false, + "features": [ + "std" + ] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "auths-ports", + "target_layer": "core", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": false, + "features": [ + "std" + ] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "auths-profile-api", + "target_layer": "product", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": true, + "features": [] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "auths-profile-domains", + "target_layer": "product", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": true, + "features": [] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "auths-profile-mcp", + "target_layer": "product", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": true, + "features": [] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "auths-raw-key", + "target_layer": "core", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": false, + "features": [ + "std" + ] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "auths-registries", + "target_layer": "core", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": false, + "features": [ + "std" + ] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "auths-sdk", + "target_layer": "product", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": true, + "features": [] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "auths-signature", + "target_layer": "core", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": false, + "features": [ + "std" + ] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "auths-signature-ed25519", + "target_layer": "core", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": true, + "features": [] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "auths-verifier", + "target_layer": "core", + "scope": "internal", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": false, + "features": [ + "std" + ] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "getrandom", + "target_layer": null, + "scope": "external", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": true, + "features": [] + }, { "source": "auths-proof-python", "source_layer": "bindings", @@ -7248,6 +7498,45 @@ "abi3-py39" ] }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "serde_json", + "target_layer": null, + "scope": "external", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": false, + "features": [ + "alloc", + "preserve_order" + ] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "serde_json_canonicalizer", + "target_layer": null, + "scope": "external", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": true, + "features": [] + }, + { + "source": "auths-proof-python", + "source_layer": "bindings", + "target": "subtle", + "target_layer": null, + "scope": "external", + "kind": "normal", + "target_condition": null, + "optional": false, + "default_features": false, + "features": [] + }, { "source": "auths-proof-wasm", "source_layer": "bindings", diff --git a/bindings/customer-journey-matrix-v1.json b/bindings/customer-journey-matrix-v1.json new file mode 100644 index 00000000..e4480cb6 --- /dev/null +++ b/bindings/customer-journey-matrix-v1.json @@ -0,0 +1,61 @@ +{ + "schema": "auths.customer-journey-matrix/1", + "semanticOwner": "Rust", + "generatedCorpus": "target/binding-vectors/scenarios.json", + "journeys": [ + { + "id": "identity-shapes-and-authentication", + "rust": "core/crates/auths-identity/src/lib.rs", + "typescript": "bindings/typescript/test/integration/identity.test.js", + "python": "bindings/python/tests/test_elite_sdk.py" + }, + { + "id": "verification-three-valued-and-mutation", + "rust": "core/fixtures/v1", + "typescript": "bindings/typescript/test/integration/scenario-corpus.test.js", + "python": "bindings/python/tests/test_api.py" + }, + { + "id": "attach-delegate-and-attenuate", + "rust": "core/crates/auths-author/src/lib.rs", + "typescript": "bindings/typescript/test/integration/workflow/delegation.test.js", + "python": "bindings/python/tests/test_workflow.py" + }, + { + "id": "proof-plan-composition", + "rust": "core/crates/auths-author/src/lib.rs", + "typescript": "bindings/typescript/test/integration/authorization-plans.test.js", + "python": "bindings/python/tests/test_elite_sdk.py" + }, + { + "id": "closed-profile-command-and-plan", + "rust": "product/profiles", + "typescript": "bindings/typescript/test/integration/domain-profiles.test.js", + "python": "bindings/python/tests/test_mcp_workflow.py" + }, + { + "id": "trust-status-and-lifecycle", + "rust": "product/runtime/auths-lifecycle", + "typescript": "bindings/typescript/test/integration/lifecycle-trust.test.js", + "python": "bindings/python/tests/test_elite_sdk.py" + }, + { + "id": "replay-budget-and-outcome-unknown", + "rust": "product/runtime/auths-lifecycle/src/kernel.rs", + "typescript": "bindings/typescript/test/unit/runtime-contract.test.js", + "python": "bindings/python/tests/test_elite_sdk.py" + }, + { + "id": "errors-observability-and-redaction", + "rust": "core/crates/auths-verifier/src/lib.rs", + "typescript": "bindings/typescript/test/unit/observability.test.js", + "python": "bindings/python/tests/test_elite_sdk.py" + }, + { + "id": "installed-artifact-and-type-safety", + "rust": "bindings/wasm/auths-proof-wasm/examples/generate-node-vectors.rs", + "typescript": "bindings/typescript/test/package", + "python": ".github/workflows/python-sdk.yml" + } + ] +} diff --git a/bindings/python-adapters/sqlite/README.md b/bindings/python-adapters/sqlite/README.md new file mode 100644 index 00000000..a19b12cd --- /dev/null +++ b/bindings/python-adapters/sqlite/README.md @@ -0,0 +1,19 @@ +# Auths SQLite runtime store + +`auths-sqlite` is the maintained durable reference implementation of the +Auths Python challenge, budget, command, and receipt store ports. It uses only +the Python standard library and imports all lifecycle and capacity meaning +from `auths.runtime`. + +```python +from auths_sqlite import SQLiteRuntimeStore + +store = SQLiteRuntimeStore("auths-runtime.sqlite3", budget_ceilings={ + "numeric-ceiling-v1": 100, +}) +``` + +The adapter demonstrates atomic compare-and-swap, replay claims, budget +reservations, and receipt idempotency. Applications remain responsible for +database backup, encryption, filesystem permissions, availability, and +operational recovery. diff --git a/bindings/python-adapters/sqlite/pyproject.toml b/bindings/python-adapters/sqlite/pyproject.toml new file mode 100644 index 00000000..0b9f1dd0 --- /dev/null +++ b/bindings/python-adapters/sqlite/pyproject.toml @@ -0,0 +1,20 @@ +[build-system] +requires = ["hatchling>=1.27,<2"] +build-backend = "hatchling.build" + +[project] +name = "auths-sqlite" +version = "1.0.0rc1" +description = "SQLite runtime-store adapter for Auths Python" +requires-python = ">=3.9" +dependencies = ["auths==1.0.0rc1"] +license = "MIT OR Apache-2.0" +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] + +[tool.hatch.build.targets.wheel] +packages = ["python/auths_sqlite"] diff --git a/bindings/python-adapters/sqlite/pyrightconfig.json b/bindings/python-adapters/sqlite/pyrightconfig.json new file mode 100644 index 00000000..d061fbcd --- /dev/null +++ b/bindings/python-adapters/sqlite/pyrightconfig.json @@ -0,0 +1,6 @@ +{ + "include": ["python/auths_sqlite"], + "extraPaths": ["../../python/python"], + "pythonVersion": "3.9", + "typeCheckingMode": "strict" +} diff --git a/bindings/python-adapters/sqlite/python/auths_sqlite/__init__.py b/bindings/python-adapters/sqlite/python/auths_sqlite/__init__.py new file mode 100644 index 00000000..c5bd3972 --- /dev/null +++ b/bindings/python-adapters/sqlite/python/auths_sqlite/__init__.py @@ -0,0 +1,242 @@ +"""Durable SQLite implementation of Auths runtime-store ports.""" + +from __future__ import annotations + +import asyncio +import sqlite3 +from pathlib import Path +from typing import Literal, Mapping, Optional, Tuple, Union, cast + +from auths.runtime import ( + BudgetReservation, + ChallengeClaim, + CommandState, + LifecycleState, + RuntimeKernel, +) + + +class SQLiteRuntimeStore: + def __init__( + self, + path: Union[str, Path], + *, + budget_ceilings: Optional[Mapping[str, int]] = None, + ) -> None: + self._path = str(Path(path)) + ceilings = dict(budget_ceilings or {}) + if any(not key or value < 0 or value > (1 << 63) - 1 for key, value in ceilings.items()): + raise ValueError("invalid budget ceiling") + self._initialize(ceilings) + + async def issue(self, challenge: bytes, *, expires_at: int) -> bool: + value = bytes(challenge) + if len(value) != 32 or expires_at < 0: + raise ValueError("invalid challenge") + return await asyncio.to_thread(self._issue, value, expires_at) + + async def claim(self, challenge: bytes, *, now: int) -> ChallengeClaim: + return await asyncio.to_thread(self._claim, bytes(challenge), now) + + async def reserve( + self, action_commitment: bytes, algebra: str, amount: int + ) -> BudgetReservation: + commitment = bytes(action_commitment) + if len(commitment) != 32 or not algebra or amount < 0 or amount > (1 << 63) - 1: + raise ValueError("invalid budget reservation") + return await asyncio.to_thread(self._reserve, commitment, algebra, amount) + + async def load(self, command_id: str) -> Optional[CommandState]: + return await asyncio.to_thread(self._load, command_id) + + async def compare_and_swap( + self, expected_revision: Optional[int], state: CommandState + ) -> Literal["stored", "conflict"]: + return await asyncio.to_thread(self._compare_and_swap, expected_revision, state) + + async def put( + self, receipt_id: str, receipt: bytes + ) -> Literal["stored", "duplicate"]: + return await asyncio.to_thread(self._put, receipt_id, bytes(receipt)) + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self._path, timeout=5, isolation_level=None) + connection.execute("PRAGMA foreign_keys = ON") + connection.execute("PRAGMA busy_timeout = 5000") + return connection + + def _initialize(self, ceilings: Mapping[str, int]) -> None: + with self._connect() as connection: + connection.executescript( + """ + PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS challenges ( + challenge BLOB PRIMARY KEY, expires_at INTEGER NOT NULL, claimed INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS budget_ceilings ( + algebra TEXT PRIMARY KEY, ceiling INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS budget_reservations ( + commitment BLOB PRIMARY KEY, algebra TEXT NOT NULL, amount INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS commands ( + command_id TEXT PRIMARY KEY, action BLOB NOT NULL, authority BLOB NOT NULL, + context BLOB NOT NULL, state TEXT NOT NULL, revision INTEGER NOT NULL, + idempotency_key TEXT NOT NULL, observed_at INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS receipts ( + receipt_id TEXT PRIMARY KEY, receipt BLOB NOT NULL + ); + """ + ) + for algebra, ceiling in ceilings.items(): + connection.execute( + "INSERT INTO budget_ceilings(algebra, ceiling) VALUES(?, ?) " + "ON CONFLICT(algebra) DO UPDATE SET ceiling=excluded.ceiling", + (algebra, ceiling), + ) + + def _issue(self, challenge: bytes, expires_at: int) -> bool: + with self._connect() as connection: + cursor = connection.execute( + "INSERT OR IGNORE INTO challenges VALUES(?, ?, 0)", + (challenge, expires_at), + ) + return cursor.rowcount == 1 + + def _claim(self, challenge: bytes, now: int) -> ChallengeClaim: + if len(challenge) != 32 or now < 0: + raise ValueError("invalid challenge claim") + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + "SELECT expires_at, claimed FROM challenges WHERE challenge=?", (challenge,) + ).fetchone() + if row is None: + connection.execute("COMMIT") + return "missing" + expires_at, claimed = cast(Tuple[int, int], row) + if now > expires_at: + connection.execute("COMMIT") + return "expired" + if claimed: + connection.execute("COMMIT") + return "duplicate" + connection.execute( + "UPDATE challenges SET claimed=1 WHERE challenge=?", (challenge,) + ) + connection.execute("COMMIT") + return "claimed" + + def _reserve( + self, commitment: bytes, algebra: str, amount: int + ) -> BudgetReservation: + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + existing = connection.execute( + "SELECT algebra, amount FROM budget_reservations WHERE commitment=?", + (commitment,), + ).fetchone() + if existing is not None: + if cast(Tuple[str, int], existing) != (algebra, amount): + connection.execute("ROLLBACK") + raise ValueError("action commitment is bound to another reservation") + connection.execute("COMMIT") + return "duplicate" + ceiling_row = connection.execute( + "SELECT ceiling FROM budget_ceilings WHERE algebra=?", (algebra,) + ).fetchone() + if ceiling_row is None: + connection.execute("COMMIT") + return "unavailable" + used_row = connection.execute( + "SELECT COALESCE(SUM(amount), 0) FROM budget_reservations WHERE algebra=?", + (algebra,), + ).fetchone() + ceiling = cast(Tuple[int], ceiling_row)[0] + used = cast(Tuple[int], used_row)[0] + if not RuntimeKernel().additive_capacity( + ceiling=ceiling, committed=used, active=0, requested=amount + ): + connection.execute("COMMIT") + return "exhausted" + connection.execute( + "INSERT INTO budget_reservations VALUES(?, ?, ?)", + (commitment, algebra, amount), + ) + connection.execute("COMMIT") + return "reserved" + + def _load(self, command_id: str) -> Optional[CommandState]: + with self._connect() as connection: + row = connection.execute( + "SELECT command_id, action, authority, context, state, revision, " + "idempotency_key, observed_at FROM commands WHERE command_id=?", + (command_id,), + ).fetchone() + if row is None: + return None + values = cast(Tuple[str, bytes, bytes, bytes, str, int, str, int], row) + return CommandState( + values[0], + values[1], + values[2], + values[3], + cast(LifecycleState, values[4]), + values[5], + values[6], + values[7], + ) + + def _compare_and_swap( + self, expected_revision: Optional[int], state: CommandState + ) -> Literal["stored", "conflict"]: + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + "SELECT revision FROM commands WHERE command_id=?", (state.command_id,) + ).fetchone() + revision = None if row is None else cast(Tuple[int], row)[0] + if revision != expected_revision: + connection.execute("COMMIT") + return "conflict" + connection.execute( + "INSERT INTO commands VALUES(?, ?, ?, ?, ?, ?, ?, ?) " + "ON CONFLICT(command_id) DO UPDATE SET action=excluded.action, " + "authority=excluded.authority, context=excluded.context, state=excluded.state, " + "revision=excluded.revision, idempotency_key=excluded.idempotency_key, " + "observed_at=excluded.observed_at", + ( + state.command_id, + state.action_commitment, + state.authority_commitment, + state.context_commitment, + state.state, + state.revision, + state.idempotency_key, + state.observed_at, + ), + ) + connection.execute("COMMIT") + return "stored" + + def _put(self, receipt_id: str, receipt: bytes) -> Literal["stored", "duplicate"]: + if not receipt_id or not receipt: + raise ValueError("invalid receipt") + with self._connect() as connection: + connection.execute("BEGIN IMMEDIATE") + row = connection.execute( + "SELECT receipt FROM receipts WHERE receipt_id=?", (receipt_id,) + ).fetchone() + if row is not None: + if cast(Tuple[bytes], row)[0] != receipt: + connection.execute("ROLLBACK") + raise ValueError("receipt identifier is bound to different bytes") + connection.execute("COMMIT") + return "duplicate" + connection.execute("INSERT INTO receipts VALUES(?, ?)", (receipt_id, receipt)) + connection.execute("COMMIT") + return "stored" + + +__all__ = ["SQLiteRuntimeStore"] diff --git a/bindings/python-adapters/sqlite/python/auths_sqlite/py.typed b/bindings/python-adapters/sqlite/python/auths_sqlite/py.typed new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/bindings/python-adapters/sqlite/python/auths_sqlite/py.typed @@ -0,0 +1 @@ + diff --git a/bindings/python-adapters/sqlite/tests/test_sqlite_store.py b/bindings/python-adapters/sqlite/tests/test_sqlite_store.py new file mode 100644 index 00000000..4d4ca33d --- /dev/null +++ b/bindings/python-adapters/sqlite/tests/test_sqlite_store.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from auths.runtime import CommandState +from auths_sqlite import SQLiteRuntimeStore + + +@pytest.mark.asyncio +async def test_sqlite_store_persists_atomic_runtime_state(tmp_path: Path) -> None: + path = tmp_path / "runtime.sqlite3" + store = SQLiteRuntimeStore(path, budget_ceilings={"numeric-ceiling-v1": 3}) + challenge = bytes([1]) * 32 + assert await store.issue(challenge, expires_at=100) + assert await store.claim(challenge, now=10) == "claimed" + assert await store.claim(challenge, now=10) == "duplicate" + assert await store.reserve(bytes([2]) * 32, "numeric-ceiling-v1", 2) == "reserved" + assert await store.reserve(bytes([3]) * 32, "numeric-ceiling-v1", 2) == "exhausted" + state = CommandState( + "command-1", + bytes([4]) * 32, + bytes([5]) * 32, + bytes([6]) * 32, + "decision-recorded", + 0, + "request-1", + 10, + ) + assert await store.compare_and_swap(None, state) == "stored" + assert await store.compare_and_swap(None, state) == "conflict" + assert await SQLiteRuntimeStore(path).load("command-1") == state + assert await store.put("receipt-1", b"receipt") == "stored" + assert await store.put("receipt-1", b"receipt") == "duplicate" diff --git a/bindings/python/Cargo.toml b/bindings/python/Cargo.toml index 514ba65a..aaf1ec3e 100644 --- a/bindings/python/Cargo.toml +++ b/bindings/python/Cargo.toml @@ -12,8 +12,30 @@ name = "_native" crate-type = ["cdylib"] [dependencies] -auths-proof-wasm.workspace = true +auths-author = { workspace = true, features = ["std"] } +auths-codec = { workspace = true, features = ["std"] } +auths-custody.workspace = true +auths-did-keri = { workspace = true, features = ["std"] } +auths-did-key = { workspace = true, features = ["std"] } +auths-identity.workspace = true +auths-identity-raw-key.workspace = true +auths-lifecycle = { workspace = true, features = ["std"] } +auths-model = { workspace = true, features = ["std"] } +auths-ports = { workspace = true, features = ["std"] } +auths-profile-api.workspace = true +auths-profile-domains.workspace = true +auths-profile-mcp.workspace = true +auths-raw-key = { workspace = true, features = ["std"] } +auths-registries = { workspace = true, features = ["std"] } +auths-signature = { workspace = true, features = ["std"] } +auths-signature-ed25519.workspace = true +auths-sdk.workspace = true +auths-verifier = { workspace = true, features = ["std"] } +getrandom.workspace = true pyo3.workspace = true +serde_json.workspace = true +serde_json_canonicalizer.workspace = true +subtle.workspace = true [lints] workspace = true diff --git a/bindings/python/README.md b/bindings/python/README.md index bd2eca27..c468457f 100644 --- a/bindings/python/README.md +++ b/bindings/python/README.md @@ -1,26 +1,178 @@ # Auths for Python -The `auths` package embeds the Auths Proof Protocol V1 verifier. Verification is -deterministic, performs no I/O, and accepts exactly three byte strings: +`auths` is the Python SDK for identity exchange, authentication, delegated +authority, protected actions, and reliable effect execution. Protocol meaning +is implemented by the embedded Rust core; Python coordinates typed application +values and replaceable async providers. + +Release wheels include the native core. Applications do not need Rust, Node, +a hosted Auths service, or private-key export. + +## Identity without permissions + +`auths.identity` is credential-shape agnostic. An identity method owns its +method material, while a relationship names its purpose, suite, and one or +more opaque verification-material objects. A suite may therefore consume one +Ed25519 key, a P-256 credential, a threshold set, a classical/post-quantum +hybrid, or resolver-provided material without changing the identity API. + +```python +from auths.identity import IdentityRegistry, decode_identity + +registry = IdentityRegistry(methods=[method], suites=[suite]) +decoded = decode_identity(packet) +resolved = await decoded.resolve(registry) +validated = await resolved.validate(registry) +authenticated = await validated.authenticate( + message, + signature, + registry, + relationship_id="signing-2026", +) +``` + +Decoded, resolved, validated, and authenticated identities are distinct types. +Authentication grants no permission. The explicit `authority_input` bridge +preserves method, relationship, suite, purpose, provenance, and assurance when +an application later chooses to introduce authority. + +The runnable [identity quickstart](examples/identity_quickstart.py) uses +clearly named development adapters. Production applications replace them with +method, resolver, and suite adapters; Auths does not own that ecosystem. + +Importing `auths.identity` does not load workflow, approvals, trust, lifecycle, +profiles, or runtime modules. + +`auths.integrations.exchange_identity` is a bounded async byte-transport port. +It carries public identity packets without importing or creating authority. + +## Verification without workflow + +Teams that already possess proof, action, and trusted-context bytes use the +effect-free verifier directly: + +```python +from auths.verify import Authorized, Denied, Indeterminate, verify + +decision = verify(proof_cbor, action_cbor, trusted_context_cbor) + +match decision: + case Authorized(): + record(decision) + case Denied() | Indeterminate(): + record(decision) +``` + +The public result is inert evidence and cannot become a gateway command. +`auths.inspection` provides bounded projections. `auths.diagnostics` accepts +caller-supplied or differential engines, and its output is always inert. +`verify_many` is bounded, order-preserving, releases the GIL during pure native +work, and has the same result meaning as independent `verify` calls. + +## Protected actions + +The integrated workflow loads trust, binds a signed root grant, delegates only +narrower authority, obtains approval, signs the exact transaction, verifies it +locally, and returns a profile-specific one-use command only for an authorized +result. ```python -from auths import verify +from auths import Approval, AuthsClient +from auths.profiles.mcp import McpAuthorized, mcp + +profile = mcp.profile(service="reports") +approval = Approval.every_action("approval.reports", approval_provider) -result = verify(proof_cbor, canonical_action_cbor, trusted_context_cbor) -if result.kind == "authorized": - execute(profile.decode_verified(result.action)) -else: - log(result.explanation.code, result.explanation.message) +async with AuthsClient( + signer=signer, + trusted_authority=trusted_authority, + telemetry=telemetry, +) as client: + async with await client.attach_agent( + name="reports-agent", + profile=profile, + authority=root_grant, + approval=approval, + ) as agent: + decision = await agent.authorize( + profile.call("publish_report", {"month": "august"}) + ) + if isinstance(decision, McpAuthorized): + response, receipt = await profile.gateway(execute).execute( + decision.command, + idempotency_key=request_id, + ) ``` -Release wheels include the native verifier; consumers do not need Rust or a C -compiler. +MCP plans commit exact order and membership. Plan-once approval is finite, +bound to that commitment, and cannot leak commands from a partial plan. The +installed-wheel [full workflow consumer](external/full_workflow_consumer.py) +is executed in CI on Linux, macOS, and Windows with the Rust toolchain removed. + +## Profiles + +Two maintained profiles prove the closed-command boundary: + +- `auths.profiles.mcp` protects canonical MCP tool calls; +- `auths.profiles.http` protects canonical origin-bound HTTP requests and + returns profile receipts. + +`auths.profile_kit` lets applications define another typed profile. Its +canonicalizer and decoder remain profile-owned, while Rust constructs the +canonical action, commits plans, verifies proofs, and brands matching one-use +commands. The kit deliberately has no generic executor. + +All effectful gateways require an idempotency key. They consume the native +command before calling application code and report `outcome-unknown` when a +provider may have been entered without a trustworthy outcome. Receipts bind +the exact action, proof authority, trusted context, native lifecycle state, +observed provider outcome, and ordered plan membership when applicable. + +## Trust, lifecycle, approvals, and runtime + +- `auths.trust` compiles typed anchors, assurance requirements, proof plans, + status snapshots, evidence limits, and offline evidence into a native + trusted context. +- `auths.lifecycle` authors signed principal and grant status, builds typed + snapshots, and supplies withdrawal, rotation, and compromise recipes; + `auths.trust.replace_policy` performs a clean current-policy replacement. +- `auths.authority` exposes attenuation and Rust-owned all-of, any-of, and + threshold proof plans. +- `auths.approvals` supports committed no-approval, grant-only, every-action, + risk-gated, custom, exact plan-once, and bounded threshold-provider paths. +- `auths.runtime` exposes Rust-owned transition, replay, additive budget, and + exclusive-capacity decisions behind challenge, budget, command, receipt, + clock, executor, and reconciliation protocols. Its in-memory implementation + is for deterministic development and conformance tests. + +Provider orchestration is async-native. The SDK has no second blocking facade, +hidden event loop, hidden retry, or claim of remote atomicity or exactly-once +execution. + +## Errors and operations + +`AuthsError` exposes bounded family, code, operation, stage, correlation, +retry, effect-state, remediation, and cause-code fields. SDK representations, +events, timelines, and support bundles reject secret-bearing or unbounded +attributes. Raw proof, signature, credential, private material, and provider +payloads are not placed in operational messages. + +`auths.testkit` contains explicit development adapters and executable port +checks. Production signers, approval systems, resolvers, stores, telemetry +exporters, transports, and frameworks remain replaceable integrations. +Maintained boundary recipes are in +[Python integration recipes](docs/INTEGRATION_RECIPES.md); durable state is +demonstrated by the separately packaged `auths-sqlite` adapter. + +## Release boundary -## Adoption layer +This package is prelaunch. There are no compatibility shims, deprecated +aliases, legacy readers, migration helpers, dual execution paths, or old/new +ABI windows. `auths.advanced`, `auths.native`, and `auths.mcp` do not exist. -This binding currently exposes the deterministic delegated-authority verifier -(Level 3 of the repository's adoption ladder). Importing `auths` performs no -identity exchange, approval, profile-gateway, receipt, or lifecycle setup. The -neutral identity protocol is owned by the smaller Rust `auths-identity` surface -and its TypeScript/WASM binding; Python does not claim an independent identity -encoding implementation. +The current package/native pair uses ABI 2 and fails closed on disagreement. +Repository qualification covers abi3 wheels for CPython 3.9–3.14 on Linux, +macOS, and Windows, strict mypy and Pyright, exact public API and wheel-content +snapshots, differential fixtures, hostile-handle checks, and installed-wheel +consumers. Publication, production readiness, and independent-review claims +remain blocked until their separate evidence gates pass. diff --git a/bindings/python/adapter-contracts.json b/bindings/python/adapter-contracts.json new file mode 100644 index 00000000..bcb5a36f --- /dev/null +++ b/bindings/python/adapter-contracts.json @@ -0,0 +1,50 @@ +{ + "schema": "auths.python-adapter-contracts/1", + "package": "auths", + "contractVersion": 1, + "ports": { + "signer": 1, + "approval": 1, + "identityMethod": 1, + "signatureSuite": 1, + "identityResolver": 1, + "evidenceProvider": 1, + "statusProvider": 1, + "clock": 1, + "challengeStore": 1, + "budgetStore": 1, + "commandStore": 1, + "receiptStore": 1, + "telemetry": 1, + "gateway": 1, + "reconciler": 1, + "transport": 1, + "framework": 1 + }, + "qualification": { + "required": [ + "exact protocol declaration", + "executable conformance suite", + "dependency and credential-boundary review", + "timeout and cancellation behavior", + "support owner and security-claim declaration" + ], + "basePackageDefinesSemantics": false, + "thirdPartyAdaptersMayExtendSemantics": false + }, + "referenceAdapters": [ + { + "module": "auths.testkit", + "purpose": "development and conformance only", + "production": false, + "supportOwner": "auths-dev" + }, + { + "module": "auths_sqlite", + "package": "auths-sqlite", + "purpose": "durable runtime-store reference", + "production": false, + "supportOwner": "auths-dev" + } + ] +} diff --git a/bindings/python/api/public-api.txt b/bindings/python/api/public-api.txt new file mode 100644 index 00000000..5bb95fd7 --- /dev/null +++ b/bindings/python/api/public-api.txt @@ -0,0 +1,364 @@ +[auths] +ActionConstraintSummary +AgentIdentity +AllowedBodies +AnyBody +Approval +ApprovalConfiguration +ApprovalDecision +ApprovalMode +ApprovalPolicy +ApprovalPolicyReference +ApprovalProvider +ApprovalRequest +ApprovalResponse +AttachedAgent +AuthorityExplanation +AuthsClient +AuthsError +AuthsWorkflowError +BudgetCeiling +BudgetSummary +ControlEvidence +DelegatedActionConstraint +DelegatedAuthority +DelegatedBudget +DelegatedStatus +DelegationReview +EffectiveAuthoritySummary +ExactBody +ExpiryOnly +InheritAction +InheritBudget +InheritStatus +NoBudget +Permission +Principal +PrincipalDescriptor +Profile +ProviderFailureKind +ProviderOperationError +ReviewField +SignatureSummary +SignedGrantInput +SignedGrantLoadRequest +SignedGrantMaterial +SignedGrantProvider +SignedGrantSource +Signer +SignerLifecycle +SigningObjectKind +SigningRequest +SigningResponse +SnapshotRequired +StatusSummary +TrustedAuthority +TrustedAuthoritySnapshot +Validity + +[auths.approvals] +Approval +ApprovalConfiguration +ApprovalDecision +ApprovalMode +ApprovalPolicy +ApprovalPolicyReference +ApprovalProvider +ApprovalRequest +ApprovalResponse +ThresholdApprovalProvider +approval_policy_reference +threshold_approval + +[auths.authority] +AllowedBodies +AnyBody +AuthorityDiff +BudgetCeiling +DelegatedAuthority +DelegationReview +ExactBody +ExpiryOnly +GrantAuthority +GrantPlan +GrantRequest +InheritAction +InheritBudget +InheritStatus +NoBudget +Permission +Principal +PrincipalDescriptor +ProofPlan +ProofPlanBuilder +ProofPlanKind +ProofReference +SignedGrantInput +SignedGrantLoadRequest +SignedGrantMaterial +SignedGrantProvider +SignedGrantSource +SignedObject +SnapshotRequired +UnsignedObject +Validity +bind_delegated_authority +grant_request_from_statement +plan_child +plan_child_fields +plan_child_statement +root_grant +validate_root_authority +validate_trusted_authority + +[auths.custody] +ControlEvidence +PrincipalDescriptor +ProviderFailureKind +ProviderOperationError +Signer +SignerLifecycle +SigningObjectKind +SigningRequest +SigningResponse + +[auths.diagnostics] +DiagnosticEngine +DiagnosticExplanation +DiagnosticResult +DiagnosticVerifier +RuntimeDiagnostic +create_diagnostic_verifier +runtime_diagnostic + +[auths.errors] +AuthsError +AuthsWorkflowError +EffectState +ErrorDetails +ProviderFailureKind +ProviderOperationError +RetryClass +RuntimeStateError + +[auths.identity] +AuthenticatedIdentity +DecodedIdentity +Ed25519SignatureSuite +IdentityMethod +IdentityPrincipal +IdentityRegistry +IdentityResolver +RawKeyIdentityMethod +ResolutionEvidence +ResolvedIdentity +ResolvedIdentityRecord +ResolverIdentityMethod +SignatureSuite +ValidatedIdentity +VerificationMaterial +VerificationRelationship +decode_identity +encode_identity +encode_raw_key_identity + +[auths.inspection] +ApprovalInspection +DecisionCommitments +DecisionInspection +DecisionSummary +InspectableDecision +InspectionMetrics +KernelSummary +authorization_plan_bytes +canonical_action_bytes +inspect_decision +mcp_action_bytes +parse_signed_object +parse_trusted_context_bytes +parse_unsigned_object +signed_object_bytes +signed_object_statement +trusted_context_bytes +unsigned_object_bytes + +[auths.integrations] +FrameworkAdapter +IdentityTransport +exchange_identity + +[auths.lifecycle] +CriticalExtension +GrantStatusRequest +GrantStatusSnapshot +IdentityRotation +LifecycleAuthor +LifecycleState +PrincipalStatusRequest +PrincipalStatusSnapshot +ProtocolDigest +SignedGrantStatus +SignedPrincipalStatus +StatusProvider +StatusSnapshot +StatusTrustRule +grant_status_snapshot +principal_status_snapshot +record_compromise +rotate_identity +withdraw_delegation + +[auths.observability] +AuthsEvent +DecisionTimeline +Telemetry +support_bundle + +[auths.profile_kit] +ApplicationAction +ApplicationAuthority +ApplicationAuthorized +ApplicationDenied +ApplicationGateway +ApplicationGatewayCancelled +ApplicationGatewayError +ApplicationIndeterminate +ApplicationPlan +ApplicationPlanAuthorized +ApplicationPlanDenied +ApplicationPlanIndeterminate +ApplicationPlanResult +ApplicationProfile +ApplicationReceipt +ApplicationRequest +ApplicationResult +ApplicationReview +CanonicalProfileAction +ProfileBudget +ProfileDefinition +ProfilePermission +define_profile + +[auths.profiles.http] +HttpAction +HttpAuthorizationRequest +HttpAuthorizationResult +HttpAuthorized +HttpDenied +HttpExplanation +HttpGateway +HttpGatewayCancelled +HttpGatewayError +HttpGatewayRequest +HttpIndeterminate +HttpPlan +HttpPlanAuthority +HttpPlanAuthorizationResult +HttpPlanAuthorized +HttpPlanDenied +HttpPlanIndeterminate +HttpProfile +HttpProfileError +HttpReceipt +HttpReview +http + +[auths.profiles.mcp] +ApprovalSummary +AuthorizationExplanation +AuthorizationMetrics +AuthorizationRequest +McpAction +McpAuthorizationResult +McpAuthorized +McpDenied +McpFacade +McpGateway +McpGatewayCall +McpGatewayCancelled +McpGatewayError +McpIndeterminate +McpPlan +McpPlanAuthority +McpPlanAuthorizationResult +McpPlanAuthorized +McpPlanDenied +McpPlanIndeterminate +McpPlanMemberAuthorized +McpPlanMemberResult +McpProfile +McpReceipt +McpReview +mcp + +[auths.runtime] +BudgetReservation +BudgetStore +ChallengeClaim +ChallengeStore +Clock +ClosedExecutor +CommandState +CommandStore +InMemoryRuntimeStore +LifecycleState +ReceiptStore +Reconciler +ReplayClass +RuntimeApplied +RuntimeKernel +RuntimeOperation +RuntimeRejected +RuntimeTransition +SystemClock +TransitionGates + +[auths.testkit] +ADAPTER_CONTRACT_VERSION +DevelopmentApproval +DevelopmentIdentityMethod +DevelopmentSignatureSuite +DevelopmentSigner +FixedClock +MemoryGateway +RecordingTelemetry +check_approval_provider +check_identity_method +check_signer +check_telemetry + +[auths.trust] +AssurancePolicy +AssuranceQuantifier +AssuranceRequirement +AssuranceRole +CompiledTrust +EvidenceProvenance +EvidenceProvider +EvidenceRequest +OfflineEvidenceBundle +PolicyReplacement +ResolvedEvidence +StatusSnapshot +TrustAnchor +TrustedAuthority +TrustedAuthoritySnapshot +TrustedContext +compile_trust +compile_trusted_context +load_evidence +parse_trusted_context +replace_policy +self_contained_configuration +status_snapshot + +[auths.verify] +Authorized +Denied +Explanation +Indeterminate +VerificationInput +VerificationMetrics +VerificationResult +verify +verify_many diff --git a/bindings/python/differential-fixtures-v1.json b/bindings/python/differential-fixtures-v1.json new file mode 100644 index 00000000..40b6d79f --- /dev/null +++ b/bindings/python/differential-fixtures-v1.json @@ -0,0 +1,93 @@ +{ + "schema": "auths.python-differential-fixtures/1", + "nativeAbiVersion": 2, + "generator": "auths-proof-wasm/generate-node-vectors", + "consumers": [ + "Rust", + "Python", + "TypeScript" + ], + "fixtures": [ + { + "name": "authorized-portable-result", + "inputs": [ + "core/fixtures/v1/valid/raw-key-chain.proof.cbor", + "core/fixtures/v1/valid/raw-key-chain.action.cbor", + "authorized.context.cbor" + ], + "projection": "authorized.result.cbor" + }, + { + "name": "child-grant-attenuation", + "inputs": [ + "authoring.delegation-root-grant.cbor", + "authoring.proposed-grant.cbor" + ], + "projection": "authoring.planned-grant.cbor" + }, + { + "name": "exact-action-signing", + "inputs": [ + "core/fixtures/v1/valid/raw-key-chain.proof.cbor" + ], + "projection": "authoring.action-signing-preimage.cbor" + }, + { + "name": "mcp-profile-action", + "inputs": [ + "mcp.signed-root-grant.cbor", + "{\"value\":\"reviewed\"}", + "mcp.action-signature.bin", + "mcp.denied-action-signature.bin", + "mcp.signed-child-grant.cbor", + "mcp.child-grant-signature.bin", + "mcp.child-action-signature.bin", + "mcp.child-evidence.bin", + "mcp.child-principal.txt" + ], + "projection": "profile, permission, audience, resource, canonical arguments and signing request" + }, + { + "name": "root-authority-attachment", + "inputs": [ + "authoring.delegation-root-grant.cbor", + "authorized.context.cbor" + ], + "projection": "grant identifier, issuer, subject, profile, permissions, validity, audiences, action constraint, budget, depth, status, assurance, extensions and signature descriptor" + }, + { + "name": "delegated-authority-workflow", + "inputs": [ + "authoring.delegation-root-grant.cbor", + "authoring.proposed-grant.cbor" + ], + "projection": "planned child statement, semantic diff, warnings, signing request identifier, transaction digest and signed child authority summary" + }, + { + "name": "full-workflow-projection", + "inputs": [ + "workflow.proof.cbor", + "workflow.action.cbor", + "workflow.context.cbor", + "workflow.result.cbor" + ], + "projection": "workflow.projection.json" + }, + { + "name": "credential-shape-agnostic-identity", + "inputs": [ + "bindings/python/identity-conformance-v1.json" + ], + "projection": "method material, purpose, suite and ordered verification-material relationships" + }, + { + "name": "profile-command-receipt-binding", + "inputs": [ + "workflow.proof.cbor", + "workflow.action.cbor", + "workflow.context.cbor" + ], + "projection": "action, proof-authority, verifier-context and ordered plan-member commitments" + } + ] +} diff --git a/bindings/python/docs/INTEGRATION_RECIPES.md b/bindings/python/docs/INTEGRATION_RECIPES.md new file mode 100644 index 00000000..773b588c --- /dev/null +++ b/bindings/python/docs/INTEGRATION_RECIPES.md @@ -0,0 +1,92 @@ +# Python integration recipes + +These recipes define boundaries, not Auths semantics. Each adapter must declare +contract version 1, run the `auths.testkit` checks that apply to its port, and +name its support owner and credential boundary. + +## Remote KMS or HSM custody + +Implement `auths.custody.Signer`. `public_identity()` returns the public +descriptor for the configured remote key. `sign(request)` sends only +`request.signing_preimage` to the selected key and returns the exact +`request_id`, `principal`, and `transaction_digest` unchanged with the remote +signature. Bound the provider timeout, propagate task cancellation, perform no +hidden retry, and map ambiguous remote outcomes to `ProviderOperationError`. +Never accept arbitrary bytes outside `SigningRequest` and never export a +private key. + +```python +class KmsSigner: + kind = "example.kms" + lifecycle = "durable" + + async def public_identity(self): + return self._descriptor + + async def sign(self, request): + signature = await self._client.sign( + key_id=self._key_id, + message=request.signing_preimage, + timeout=self._timeout, + ) + return SigningResponse( + request.request_id, + request.principal, + request.transaction_digest, + signature, + ) + + async def aclose(self): + await self._client.aclose() +``` + +## Resolver-backed identity + +Implement `IdentityResolver` and compose it with `ResolverIdentityMethod`. +Honor `maximum_bytes`, return one exact method and identity, preserve +provenance and history, and reject redirects or private-network destinations +unless the application explicitly configured them. The resolver returns typed +relationships and opaque material; the selected suite adapter interprets that +material. + +## Durable SQLite runtime state + +Install the separately versioned `auths-sqlite` package and construct +`auths_sqlite.SQLiteRuntimeStore`. The adapter supplies atomic challenge +claims, budget reservations, command compare-and-swap, and idempotent receipt +storage. Its database is an operational component: configure filesystem +permissions, encryption, backups, monitoring, and recovery for the deployment. + +## OpenTelemetry + +Implement `auths.observability.Telemetry.emit` by mapping `AuthsEvent.name`, +`operation`, `stage`, `outcome`, `observed_at`, and bounded attributes to the +deployment's OpenTelemetry API. Do not attach proof bytes, signatures, +credentials, keys, request bodies, provider payloads, or idempotency keys. +Exporter failures must not change an authorization result or execute an +effect. + +## FastAPI + +Create one application-owned dependency that builds a profile action from +already parsed route inputs, calls `AttachedAgent.authorize`, and renders the +three result variants. Pass the request's application idempotency identifier +to the matching gateway. Keep authentication, authority, retry, and receipt +meaning in Auths; the framework adapter owns only request lifetime and HTTP +translation. + +```python +async def authorize_publish(report_id: str, request_id: str): + decision = await agent.authorize(reports.publish(report_id)) + if decision.kind == "authorized": + return await gateway.execute( + decision.command, + idempotency_key=request_id, + ) + if decision.kind == "denied": + raise HTTPException(status_code=403, detail=decision.code) + raise HTTPException(status_code=503, detail=decision.code) +``` + +No framework, KMS, resolver, database, or telemetry vendor is imported by the +base `auths` wheel. diff --git a/bindings/python/examples/identity_quickstart.py b/bindings/python/examples/identity_quickstart.py new file mode 100644 index 00000000..da6bcb71 --- /dev/null +++ b/bindings/python/examples/identity_quickstart.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import asyncio + +from auths.identity import ( + IdentityRegistry, + VerificationMaterial, + VerificationRelationship, + decode_identity, + encode_identity, +) +from auths.testkit import DevelopmentIdentityMethod, DevelopmentSignatureSuite + + +async def main() -> None: + relationship = VerificationRelationship( + "default-signing", + "authentication", + "auths.test-signature", + (VerificationMaterial("credential", b"public-development-material"),), + ) + packet = encode_identity( + "auths.test-identity", + "identity:example:alice", + relationships=(relationship,), + ) + registry = IdentityRegistry( + methods=[DevelopmentIdentityMethod()], + suites=[DevelopmentSignatureSuite()], + ) + validated = await decode_identity(packet).validate(registry) + authenticated = await validated.authenticate( + b"publish report", + b"auths-development-signature", + registry, + ) + print(authenticated.identity_id) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/bindings/python/external/full_workflow_consumer.py b/bindings/python/external/full_workflow_consumer.py new file mode 100644 index 00000000..88a9dd56 --- /dev/null +++ b/bindings/python/external/full_workflow_consumer.py @@ -0,0 +1,164 @@ +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +from auths import ( + Approval, + ApprovalRequest, + ApprovalResponse, + AttachedAgent, + AuthsClient, + ControlEvidence, + Principal, + PrincipalDescriptor, + SignedGrantMaterial, + SigningRequest, + SigningResponse, + TrustedAuthority, +) +from auths.profiles.mcp import ( + AuthorizationRequest, + McpGatewayCall, + McpPlanAuthorized, + mcp, +) +from auths.inspection import ( + inspect_decision, + parse_signed_object, + parse_trusted_context_bytes, +) + + +class ApprovalProvider: + def __init__(self) -> None: + self.calls = 0 + + async def approve(self, request: ApprovalRequest) -> ApprovalResponse: + self.calls += 1 + return ApprovalResponse( + request.request_id, + request.transaction_digest, + request.policy, + "approved", + ) + + +class FixtureSigner: + kind = "external-consumer-fixture" + lifecycle = "ephemeral" + + def __init__(self, vectors: Path, principal: Principal) -> None: + self._vectors = vectors + self._principal = principal + self.calls = 0 + self.closed = False + + async def public_identity(self) -> PrincipalDescriptor: + return PrincipalDescriptor( + self._principal, + "raw-key-v1", + self._principal.value, + "ed25519-v1", + ) + + async def sign(self, request: SigningRequest) -> SigningResponse: + self.calls += 1 + return SigningResponse( + request.request_id, + request.principal, + request.transaction_digest, + (self._vectors / "mcp.action-signature.bin").read_bytes(), + ( + ControlEvidence( + "raw-key-v1", + "application/vnd.auths.raw-key.v1", + (self._vectors / "mcp.actor-evidence.bin").read_bytes(), + ), + ), + ) + + async def aclose(self) -> None: + self.closed = True + + +async def run(vectors: Path) -> None: + root = Principal("key:sha256:qogx823wE-Cfoq_WXwDS1D6S8jMOhJssOpaNRZOJCKs") + actor = Principal("key:sha256:MPL4hHxgoCRRtbEjYAedm50CmSM11XgLojSwwYeRi1E") + signer = FixtureSigner(vectors, actor) + approval_provider = ApprovalProvider() + approval = Approval.plan_once( + "approval.external-plan", + approval_provider, + max_uses=2, + ) + trusted = TrustedAuthority( + "external.root", + root, + parse_trusted_context_bytes((vectors / "mcp.context.cbor").read_bytes()), + approval.policy.reference, + ) + root_grant = SignedGrantMaterial( + parse_signed_object( + "grant", (vectors / "mcp.signed-root-grant.cbor").read_bytes() + ), + ( + ControlEvidence( + "raw-key-v1", + "application/vnd.auths.raw-key.v1", + (vectors / "mcp.root-evidence.bin").read_bytes(), + ), + ), + ) + profile = mcp.profile(service="reports") + executed: list[McpGatewayCall] = [] + + async def execute(call: McpGatewayCall) -> str: + executed.append(call) + return call.name + + async with AuthsClient(signer=signer, trusted_authority=trusted) as client: + agent: AttachedAgent = await client.attach_agent( + name="external-plan-agent", + profile=profile, + authority=root_grant, + approval=approval, + ) + plan = profile.plan( + ( + profile.call("update_demo_record", {"value": "reviewed"}), + profile.call("update_demo_record", {"value": "reviewed"}), + ) + ) + result = await agent.authorize_plan( + plan, + requests=( + AuthorizationRequest(bytes([0x22]) * 32, 50), + AuthorizationRequest(bytes([0x22]) * 32, 50), + ), + ) + if not isinstance(result, McpPlanAuthorized): + raise RuntimeError("installed wheel did not authorize the shared plan") + if any( + inspect_decision(member).decision.kind != "authorized" + for member in result.results + ): + raise RuntimeError("installed wheel did not preserve plan member decisions") + responses, receipts = await profile.gateway(execute).execute_plan( + result.command, idempotency_key="installed-wheel-plan" + ) + if responses != ("update_demo_record", "update_demo_record"): + raise RuntimeError("installed wheel changed ordered gateway results") + if len(receipts) != 2: + raise RuntimeError("installed wheel omitted execution receipts") + if approval_provider.calls != 1 or signer.calls != 2 or not signer.closed: + raise RuntimeError("installed wheel changed provider lifecycle semantics") + if len(executed) != 2: + raise RuntimeError("installed wheel changed exact plan execution") + + +if __name__ == "__main__": + if len(sys.argv) != 2: + raise SystemExit("usage: full_workflow_consumer.py ") + asyncio.run(run(Path(sys.argv[1]).resolve())) diff --git a/bindings/python/identity-conformance-v1.json b/bindings/python/identity-conformance-v1.json new file mode 100644 index 00000000..ac2b5233 --- /dev/null +++ b/bindings/python/identity-conformance-v1.json @@ -0,0 +1,33 @@ +{ + "schema": "auths.python-identity-conformance/1", + "descriptorProtocol": "auths-identity/v1", + "scenarios": [ + { + "id": "rotation-history", + "methodMaterial": "resolver-version-8", + "relationships": ["signing-current"], + "history": ["signing-2025", "signing-2026"], + "requiredBehavior": "method adapter proves current relationship and preserves history provenance" + }, + { + "id": "composite-all", + "relationships": ["device", "organization"], + "requiredBehavior": "method and application select both purpose-labelled relationships explicitly" + }, + { + "id": "threshold-material", + "relationship": "quorum-signing", + "materials": ["member-1", "member-2", "member-3"], + "requiredBehavior": "suite adapter owns quorum verification and rejects missing, duplicate or substituted members" + }, + { + "id": "hybrid-classical-post-quantum", + "relationship": "hybrid-authentication", + "materials": ["classical", "post-quantum"], + "requiredBehavior": "suite adapter verifies both materials without fallback or downgrade" + } + ], + "maintainedAdapters": ["raw-key-v2/ed25519-v1"], + "adapterOwnership": "external", + "claimsAdapterAvailability": false +} diff --git a/bindings/python/native-abi-v2.json b/bindings/python/native-abi-v2.json new file mode 100644 index 00000000..8504a001 --- /dev/null +++ b/bindings/python/native-abi-v2.json @@ -0,0 +1,172 @@ +{ + "schema": "auths.python-native-abi/1", + "abiVersion": 2, + "pythonFloor": "CPython 3.9", + "wheelAbi": "abi3-py39", + "semanticOwner": "Rust", + "types": [ + "Principal", + "PrincipalDescriptor", + "ApprovalPolicyReference", + "UnsignedObject", + "SignedObject", + "GrantRequest", + "AuthorityDiff", + "GrantPlan", + "GrantAuthority", + "SigningRequest", + "SigningTransaction", + "AuthorizationPlan", + "AuthorizationPlanBuilder", + "McpAction", + "McpCall", + "NativeMcpPlan", + "McpCommand", + "McpPlanCommand", + "McpGatewayCall", + "AssurancePolicy", + "TrustAnchor", + "StatusSnapshot", + "TrustedContext", + "VerifiedAction", + "NativeVerificationResult", + "IdentityProjection", + "IdentityDescriptorProjection", + "HttpCall", + "HttpAction", + "NativeHttpPlan", + "HttpCommand", + "HttpPlanCommand", + "HttpGatewayRequest", + "ApplicationAction", + "ApplicationActionPreparation", + "NativeApplicationPlan", + "ApplicationCommand", + "ApplicationPlanCommand", + "ApplicationGatewayCall" + ], + "operations": [ + "native_abi_version", + "generate_challenge_v1", + "approval_policy_reference", + "validate_trusted_authority", + "validate_root_authority", + "bind_delegated_authority", + "root_grant", + "grant_request_from_statement", + "plan_child", + "plan_child_statement", + "plan_child_fields", + "principal_status_statement", + "grant_status_statement", + "prepare_signing", + "prepare_signing_transaction", + "prepare_mcp_action", + "validate_mcp_service", + "mcp_call", + "review_mcp_call", + "prepare_mcp_call_action", + "commit_mcp_plan", + "commit_plan_approval", + "authorize_mcp", + "consume_mcp_command", + "seal_mcp_plan_command", + "consume_mcp_plan_command", + "status_snapshot", + "compile_trusted_context", + "self_contained_configuration", + "verify_v1", + "verify_many_v1", + "decode_identity_v1", + "encode_identity_descriptor_v1", + "decode_identity_descriptor_v1", + "compact_identity_descriptor_v1", + "identity_descriptor_signing_preimage_v1", + "encode_public_identity_v1", + "raw_key_identity_v2", + "validate_raw_key_identity_v2", + "identity_signing_preimage_v1", + "verify_ed25519_preimage_v1", + "http_call", + "review_http_call", + "commit_http_plan", + "prepare_http_action", + "authorize_http", + "consume_http_command", + "seal_http_plan_command", + "consume_http_plan_command", + "application_action", + "application_action_commitment_v1", + "commit_application_plan", + "prepare_application_action", + "authorize_application", + "consume_application_command", + "seal_application_plan_command", + "consume_application_plan_command", + "runtime_transition_v1", + "runtime_replay_v1", + "runtime_additive_capacity_v1", + "runtime_exclusive_capacity_v1", + "runtime_execution_state_v1", + "decode_diagnostic_result_v1", + "commit_canonical_v1", + "diagnostic_input_limits_v1", + "commitments_equal_v1" + ], + "inspection": [ + "inspect_verified_action", + "inspect_unsigned", + "inspect_signed", + "inspect_plan", + "inspect_mcp_action", + "inspect_trusted_context", + "parse_unsigned", + "parse_signed", + "parse_trusted_context", + "unsigned_from_signed" + ], + "capabilityInvariants": [ + "VerifiedAction has no Python constructor", + "VerifiedAction has no copy, pickle, subclass, mutation, or buffer path", + "only an authorized native verifier branch returns VerifiedAction", + "denied and indeterminate results contain no capability", + "canonical bytes cannot be promoted into VerifiedAction", + "McpCommand has no Python constructor", + "McpCommand has no copy, pickle, subclass, mutation, or buffer path", + "only a native authorized VerifiedAction decodes into McpCommand", + "McpCommand is service-bound and single-use", + "denied and indeterminate MCP results contain no command", + "McpPlanCommand has no Python constructor", + "McpPlanCommand has no copy, pickle, subclass, mutation, or buffer path", + "McpPlanCommand is sealed only after every exact ordered member authorizes", + "McpPlanCommand is service-bound and single-use", + "failed plans expose no command from any earlier authorized member", + "HttpCommand and HttpPlanCommand are origin-bound native one-use handles", + "ApplicationCommand and ApplicationPlanCommand are profile-bound native one-use handles", + "identity authentication never creates authority or an effect capability", + "public verification projects inert evidence and does not expose VerifiedAction", + "general identity descriptors bind method material and explicit purpose/suite/material relationships", + "diagnostic results are native-decoded evidence and cannot mint an effect capability" + ], + "workflowInvariants": [ + "approval configuration commitments are derived by auths-author", + "root authority binds the configured root, signer principal and profile", + "delegated profile and critical extensions are inherited from the parent", + "issuer and parent linkage are derived by native authoring", + "approval responses bind policy, request and transaction before signing", + "signer responses bind provider call, request, principal, descriptor and transaction through auths-custody", + "signing transactions are single-use and terminal after rejection, mismatch, expiry, cancellation or completion", + "proof and request-context assembly is bounded and owned by auths-author", + "MCP permission mapping and command decoding are owned by auths-profile-mcp", + "a gateway effect requires a native-sealed command for the exact configured service", + "plan commitments bind profile, exact membership, order, and duplicate positions", + "plan-once approval is bounded by the native plan approval commitment, policy configuration, exact use count, and expiry", + "ordered plan execution consumes the native command before application callbacks and does not claim remote atomicity", + "effectful gateways require caller-owned idempotency keys and bind receipts to native command commitments", + "profile commands carry native-derived action, proof-authority, and verifier-context receipt commitments", + "HTTP permission mapping and command decoding are owned by auths-profile-domains", + "application actions are parsed into native canonical types before authoring", + "execution lifecycle transition, replay, and capacity decisions are owned by auths-lifecycle", + "inspection commitments and resource metrics are computed from native-owned canonical values" + ] +} diff --git a/bindings/python/performance-baseline.json b/bindings/python/performance-baseline.json new file mode 100644 index 00000000..fd260d29 --- /dev/null +++ b/bindings/python/performance-baseline.json @@ -0,0 +1,34 @@ +{ + "schema": "auths.python-performance-baseline/1", + "capturedAt": "2026-08-11", + "environment": { + "implementation": "CPython", + "python": "3.10", + "operatingSystem": "darwin", + "architecture": "arm64", + "build": "release abi3 wheel", + "runner": "developer-reference" + }, + "measurements": { + "coldInitializeMs": 33.515, + "singleVerifyMsP95": 0.244, + "batch32MsP95": 6.601, + "plan64MsP95": 1.72, + "verifyPeakBytes": 87099, + "eventLoopYieldMsP95": 0.036, + "wheelBytes": 1080134 + }, + "hardLimits": { + "coldInitializeMs": 2000, + "singleVerifyMsP95": 100, + "batch32MsP95": 3000, + "plan64MsP95": 500, + "verifyPeakBytes": 50000000, + "eventLoopYieldMsP95": 20, + "wheelBytes": 25000000 + }, + "reviewThresholds": { + "runtimeRegressionPercent": 10, + "wheelSizeRegressionPercent": 15 + } +} diff --git a/bindings/python/pyproject.toml b/bindings/python/pyproject.toml index bfe48979..2df62142 100644 --- a/bindings/python/pyproject.toml +++ b/bindings/python/pyproject.toml @@ -5,13 +5,22 @@ build-backend = "maturin" [project] name = "auths" version = "1.0.0rc1" -description = "Embedded Auths SDK with offline proof verification" +description = "Embedded Auths Full Workflow SDK with native Rust semantics" readme = "README.md" requires-python = ">=3.9" license = "MIT OR Apache-2.0" classifiers = [ "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Rust", + "Operating System :: Microsoft :: Windows", + "Operating System :: MacOS", + "Operating System :: POSIX :: Linux", "Typing :: Typed", ] diff --git a/bindings/python/pyrightconfig.json b/bindings/python/pyrightconfig.json new file mode 100644 index 00000000..e3b565f2 --- /dev/null +++ b/bindings/python/pyrightconfig.json @@ -0,0 +1,17 @@ +{ + "include": [ + "python/auths", + "typecheck" + ], + "exclude": ["typecheck/mypy_negative.py"], + "pythonVersion": "3.9", + "extraPaths": ["python"], + "typeCheckingMode": "strict", + "reportUnnecessaryTypeIgnoreComment": "error", + "reportMissingModuleSource": "none", + "reportPrivateUsage": "none", + "reportUnnecessaryCast": "none", + "reportUnnecessaryIsInstance": "none", + "reportUnsupportedDunderAll": "none", + "reportUnusedFunction": "none" +} diff --git a/bindings/python/python/auths/__init__.py b/bindings/python/python/auths/__init__.py index 0d07ed88..1b2bac85 100644 --- a/bindings/python/python/auths/__init__.py +++ b/bindings/python/python/auths/__init__.py @@ -1,363 +1,80 @@ -"""Idiomatic embedded Auths SDK with Proof Protocol V1 verification.""" +"""Auths identity, authority, and protected-action SDK.""" from __future__ import annotations -from dataclasses import dataclass -from typing import Final, Literal, Union - -from ._native import verify_v1 - -VerdictKind = Literal["authorized", "denied", "indeterminate"] -VerificationStage = Literal[ - "decode", "resolve", "principal-control", "authority", "complete" -] -_MAX_RESULT_BYTES: Final = 16 * 1024 * 1024 -_MAX_DEPTH: Final = 64 -_AUTHORIZED_TOKEN: Final = object() - - -@dataclass(frozen=True) -class Explanation: - """Stable, non-sensitive result explanation.""" - - code: str - message: str - retryable: bool - - -@dataclass(frozen=True) -class VerificationMetrics: - """Deterministic input and verifier-work counters.""" - - proof_bytes: int - action_bytes: int - context_bytes: int - object_count: int - plan_leaves: int - plan_depth: int - work_units: int - - -class VerifiedAction: - """Canonical action bytes constructible only by this verifier wrapper.""" - - __slots__ = ("_canonical_action",) - - def __init__(self, token: object, canonical_action: bytes) -> None: - if token is not _AUTHORIZED_TOKEN: - raise TypeError("VerifiedAction is sealed") - self._canonical_action = bytes(canonical_action) - - @property - def canonical_bytes(self) -> bytes: - """Returns an immutable copy of the authorized canonical action.""" - - return bytes(self._canonical_action) - - -@dataclass(frozen=True) -class Authorized: - """Exact authority was established.""" - - kind: Literal["authorized"] - code: str - stage: VerificationStage - explanation: Explanation - metrics: VerificationMetrics - required_configuration: bytes | None - local_configuration: bytes - result_cbor: bytes - action: VerifiedAction - - -@dataclass(frozen=True) -class Denied: - """Available trustworthy facts established rejection.""" - - kind: Literal["denied"] - code: str - stage: VerificationStage - explanation: Explanation - metrics: VerificationMetrics - required_configuration: bytes | None - local_configuration: bytes - result_cbor: bytes - - -@dataclass(frozen=True) -class Indeterminate: - """A required trustworthy fact or implementation was unavailable.""" - - kind: Literal["indeterminate"] - code: str - stage: VerificationStage - explanation: Explanation - metrics: VerificationMetrics - required_configuration: bytes | None - local_configuration: bytes - result_cbor: bytes - - -VerificationResult = Union[Authorized, Denied, Indeterminate] - - -def verify( - proof_cbor: bytes, - canonical_action_cbor: bytes, - trusted_context_cbor: bytes, -) -> VerificationResult: - """Runs the complete embedded three-input V1 verification operation.""" - - result_cbor = bytes( - verify_v1(proof_cbor, canonical_action_cbor, trusted_context_cbor) - ) - ( - kind, - code, - stage, - metrics, - required_configuration, - local_configuration, - ) = _decode_result(result_cbor) - explanation = _explain(kind, code) - common = { - "code": code, - "stage": stage, - "explanation": explanation, - "metrics": metrics, - "required_configuration": required_configuration, - "local_configuration": local_configuration, - "result_cbor": result_cbor, - } - if kind == "authorized": - return Authorized( - kind="authorized", - action=VerifiedAction(_AUTHORIZED_TOKEN, canonical_action_cbor), - **common, - ) - if kind == "denied": - return Denied(kind="denied", **common) - return Indeterminate(kind="indeterminate", **common) - - -class _Reader: - __slots__ = ("_data", "_offset") - - def __init__(self, data: bytes) -> None: - if not data or len(data) > _MAX_RESULT_BYTES: - raise ValueError("Auths result exceeds byte bounds") - self._data = data - self._offset = 0 - - @property - def complete(self) -> bool: - return self._offset == len(self._data) - - def _take(self) -> int: - if self._offset >= len(self._data): - raise ValueError("truncated CBOR result") - value = self._data[self._offset] - self._offset += 1 - return value - - def head(self) -> tuple[int, int]: - initial = self._take() - major = initial >> 5 - additional = initial & 31 - if additional < 24: - return major, additional - widths = {24: 1, 25: 2, 26: 4, 27: 8} - width = widths.get(additional) - if width is None: - raise ValueError("indefinite CBOR is not canonical") - value = 0 - for _ in range(width): - value = (value << 8) | self._take() - minimum = {1: 24, 2: 0x100, 4: 0x1_0000, 8: 0x1_0000_0000}[width] - if value < minimum: - raise ValueError("non-minimal CBOR integer") - return major, value - - def uint(self) -> int: - major, value = self.head() - if major != 0: - raise ValueError("expected CBOR unsigned integer") - return value - - def text(self) -> str: - major, length = self.head() - if major != 3 or length > len(self._data) - self._offset: - raise ValueError("invalid CBOR text") - end = self._offset + length - value = self._data[self._offset : end].decode("utf-8", errors="strict") - self._offset = end - return value - - def nullable_bytes(self, expected_length: int) -> bytes | None: - major, length = self.head() - if major == 7 and length == 22: - return None - if ( - major != 2 - or length != expected_length - or length > len(self._data) - self._offset - ): - raise ValueError("invalid CBOR bytes") - end = self._offset + length - value = bytes(self._data[self._offset : end]) - self._offset = end - return value - - def bytes(self, expected_length: int) -> bytes: - value = self.nullable_bytes(expected_length) - if value is None: - raise ValueError("unexpected CBOR null") - return value - - def map(self) -> int: - major, length = self.head() - if major != 5 or length > 1_000_000: - raise ValueError("invalid CBOR map") - return length - - def skip(self, depth: int = 0) -> None: - if depth > _MAX_DEPTH: - raise ValueError("CBOR depth exceeded") - major, argument = self.head() - if major in (0, 1): - return - if major in (2, 3): - if argument > len(self._data) - self._offset: - raise ValueError("truncated CBOR value") - self._offset += argument - return - if major == 4: - for _ in range(argument): - self.skip(depth + 1) - return - if major == 5: - for _ in range(argument): - self.skip(depth + 1) - self.skip(depth + 1) - return - if major == 7 and argument in (20, 21, 22): - return - raise ValueError("unsupported CBOR result value") - - -def _decode_result( - data: bytes, -) -> tuple[ - VerdictKind, - str, - VerificationStage, - VerificationMetrics, - bytes | None, - bytes, -]: - reader = _Reader(data) - fields = reader.map() - if fields != 16: - raise ValueError("invalid Auths result shape") - decision: int | None = None - stage_number: int | None = None - code: str | None = None - metrics: VerificationMetrics | None = None - required_configuration: bytes | None = None - local_configuration: bytes | None = None - abi_version: int | None = None - for expected_key in range(fields): - key = reader.uint() - if key != expected_key: - raise ValueError("result map keys are not the exact canonical sequence") - if key == 0: - decision = reader.uint() - elif key == 1: - stage_number = reader.uint() - elif key == 2: - if reader.map() != 2 or reader.uint() != 0: - raise ValueError("unsupported result code shape") - reader.uint() - code_key = reader.uint() - if code_key != 1: - raise ValueError("unsupported result code shape") - code = reader.text() - elif key == 11: - metrics = _decode_metrics(reader) - elif key == 13: - required_configuration = reader.nullable_bytes(32) - elif key == 14: - local_configuration = reader.bytes(32) - elif key == 15: - abi_version = reader.uint() - else: - reader.skip() - if not reader.complete: - raise ValueError("trailing CBOR result bytes") - if abi_version != 2: - raise ValueError("unsupported Auths result ABI version") - kinds: dict[int, VerdictKind] = { - 0: "authorized", - 1: "denied", - 2: "indeterminate", - } - stages: dict[int, VerificationStage] = { - 0: "decode", - 1: "resolve", - 2: "principal-control", - 3: "authority", - 4: "complete", - } - if ( - decision not in kinds - or stage_number not in stages - or code is None - or metrics is None - or local_configuration is None - ): - raise ValueError("incomplete Auths result") - return ( - kinds[decision], - code, - stages[stage_number], - metrics, - required_configuration, - local_configuration, - ) - - -def _decode_metrics(reader: _Reader) -> VerificationMetrics: - fields = reader.map() - values: dict[int, int] = {} - previous_key = -1 - for _ in range(fields): - key = reader.uint() - if key <= previous_key: - raise ValueError("metrics map keys are not canonical") - previous_key = key - values[key] = reader.uint() - if set(values) != set(range(7)): - raise ValueError("incomplete Auths metrics") - return VerificationMetrics(*[values[index] for index in range(7)]) - - -def _explain(kind: VerdictKind, code: str) -> Explanation: - if kind == "authorized": - message = "the proof establishes exact authority for this action" - elif kind == "denied": - message = "the supplied proof does not authorize this exact action" - else: - message = "a required trustworthy fact or implementation is unavailable" - return Explanation(code=code, message=message, retryable=kind == "indeterminate") - - -__all__ = [ - "Authorized", - "Denied", - "Explanation", - "Indeterminate", - "VerificationMetrics", - "VerificationResult", - "VerifiedAction", - "verify", -] +from importlib import import_module +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .workflow import * # noqa: F403 + +_WORKFLOW_EXPORTS = ( + "ActionConstraintSummary", + "AgentIdentity", + "AllowedBodies", + "AnyBody", + "Approval", + "ApprovalConfiguration", + "ApprovalDecision", + "ApprovalMode", + "ApprovalPolicy", + "ApprovalPolicyReference", + "ApprovalProvider", + "ApprovalRequest", + "ApprovalResponse", + "AttachedAgent", + "AuthorityExplanation", + "AuthsClient", + "AuthsError", + "AuthsWorkflowError", + "BudgetCeiling", + "BudgetSummary", + "ControlEvidence", + "DelegatedActionConstraint", + "DelegatedAuthority", + "DelegatedBudget", + "DelegatedStatus", + "DelegationReview", + "EffectiveAuthoritySummary", + "ExactBody", + "ExpiryOnly", + "InheritAction", + "InheritBudget", + "InheritStatus", + "NoBudget", + "Permission", + "Principal", + "PrincipalDescriptor", + "Profile", + "ProviderFailureKind", + "ProviderOperationError", + "ReviewField", + "SignatureSummary", + "SignedGrantInput", + "SignedGrantLoadRequest", + "SignedGrantMaterial", + "SignedGrantProvider", + "SignedGrantSource", + "Signer", + "SignerLifecycle", + "SigningObjectKind", + "SigningRequest", + "SigningResponse", + "SnapshotRequired", + "StatusSummary", + "TrustedAuthority", + "TrustedAuthoritySnapshot", + "Validity", +) + +__all__ = list(_WORKFLOW_EXPORTS) + + +def __getattr__(name: str) -> Any: + if name not in _WORKFLOW_EXPORTS: + raise AttributeError(f"module 'auths' has no attribute {name!r}") + return getattr(import_module(".workflow", __name__), name) + + +def __dir__() -> list[str]: + return sorted((*globals(), *_WORKFLOW_EXPORTS)) diff --git a/bindings/python/python/auths/_native.pyi b/bindings/python/python/auths/_native.pyi new file mode 100644 index 00000000..b620e165 --- /dev/null +++ b/bindings/python/python/auths/_native.pyi @@ -0,0 +1,789 @@ +from typing import List, Literal, Optional, Tuple + +Permission = Tuple[str, str] +Budget = Tuple[str, int] + +def generate_challenge_v1() -> bytes: ... +StatusPolicy = Tuple[str, int] +CriticalExtension = Tuple[str, bytes] + +class Principal: + def __init__(self, value: str) -> None: ... + @property + def value(self) -> str: ... + +class PrincipalDescriptor: + def __init__( + self, + principal: Principal, + principal_method: str, + verification_method: str, + suite: str, + ) -> None: ... + @property + def principal(self) -> Principal: ... + @property + def principal_method(self) -> str: ... + @property + def verification_method(self) -> str: ... + @property + def suite(self) -> str: ... + def matches(self, other: PrincipalDescriptor) -> bool: ... + +class ApprovalPolicyReference: + def __init__( + self, + policy_id: str, + evaluator_version: str, + configuration_digest: bytes, + ) -> None: ... + @property + def policy_id(self) -> str: ... + @property + def evaluator_version(self) -> str: ... + @property + def configuration_digest(self) -> bytes: ... + def matches(self, other: ApprovalPolicyReference) -> bool: ... + +class UnsignedObject: + @property + def kind(self) -> str: ... + +class SignedObject: + @property + def kind(self) -> str: ... + +class GrantRequest: + def __init__( + self, + subject: Principal, + profile_id: str, + profile_version: int, + permissions: List[Permission], + not_before: int, + expires_at: int, + audiences: List[str], + body_digests: Optional[List[bytes]], + budget: Optional[Budget], + remaining_depth: int, + status: Optional[StatusPolicy], + assurance_floor: str, + extensions: List[CriticalExtension], + ) -> None: ... + +class AuthorityDiff: + @property + def removed_permissions(self) -> int: ... + @property + def removed_audiences(self) -> int: ... + @property + def validity_shortened(self) -> bool: ... + @property + def action_narrowed(self) -> bool: ... + @property + def budget_narrowed(self) -> bool: ... + @property + def status_narrowed(self) -> bool: ... + @property + def delegation_depth(self) -> Tuple[int, int]: ... + +class GrantPlan: + @property + def diff(self) -> AuthorityDiff: ... + @property + def warnings(self) -> List[str]: ... + @property + def unsigned(self) -> UnsignedObject: ... + +class GrantAuthority: + @property + def binding(self) -> Literal["root", "delegated"]: ... + @property + def grant_id(self) -> bytes: ... + @property + def issuer(self) -> Principal: ... + @property + def subject(self) -> Principal: ... + @property + def profile(self) -> Tuple[str, int]: ... + @property + def permissions(self) -> List[Permission]: ... + @property + def validity(self) -> Tuple[int, int]: ... + @property + def audiences(self) -> List[str]: ... + @property + def action_constraint(self) -> Tuple[str, int]: ... + @property + def budget(self) -> Optional[Budget]: ... + @property + def remaining_depth(self) -> int: ... + @property + def parent_id(self) -> Optional[bytes]: ... + @property + def status(self) -> Tuple[str, Optional[str], Optional[int]]: ... + @property + def assurance_floor(self) -> str: ... + @property + def critical_extensions(self) -> List[str]: ... + @property + def signature(self) -> Tuple[str, str, str]: ... + +class SigningRequest: + @property + def object_kind(self) -> str: ... + @property + def request_id(self) -> str: ... + @property + def object_id(self) -> bytes: ... + @property + def signing_preimage(self) -> bytes: ... + @property + def transaction_digest(self) -> bytes: ... + def complete(self, signature: bytes) -> SignedObject: ... + +class SigningTransaction: + @property + def object_kind(self) -> str: ... + @property + def request_id(self) -> str: ... + @property + def object_id(self) -> bytes: ... + @property + def signing_preimage(self) -> bytes: ... + @property + def transaction_digest(self) -> bytes: ... + @property + def principal(self) -> PrincipalDescriptor: ... + @property + def policy(self) -> ApprovalPolicyReference: ... + @property + def expires_at(self) -> int: ... + @property + def phase( + self, + ) -> Literal["awaiting-approval", "awaiting-signature", "terminal"]: ... + def accept_approval( + self, + request_id: str, + transaction_digest: bytes, + policy: ApprovalPolicyReference, + decision: str, + now: int, + ) -> bool: ... + def complete_response( + self, + request_id: str, + principal: PrincipalDescriptor, + transaction_digest: bytes, + signature: bytes, + now: int, + ) -> SignedObject: ... + def discard(self) -> None: ... + +class NativeDelegationExpandedError(ValueError): ... + +class AuthorizationPlan: + @property + def plan_id(self) -> bytes: ... + @property + def shape(self) -> Tuple[int, int]: ... + +class AuthorizationPlanBuilder: + def __init__(self) -> None: ... + def proof(self, reference: bytes) -> AuthorizationPlan: ... + def all_of(self, members: List[AuthorizationPlan]) -> AuthorizationPlan: ... + def any_of(self, members: List[AuthorizationPlan]) -> AuthorizationPlan: ... + def threshold( + self, required: int, members: List[AuthorizationPlan] + ) -> AuthorizationPlan: ... + +class McpAction: + @property + def unsigned(self) -> UnsignedObject: ... + @property + def audience(self) -> str: ... + @property + def resource(self) -> str: ... + @property + def display_digest_hex(self) -> str: ... + @property + def review_title(self) -> str: ... + @property + def review_fields(self) -> List[Tuple[str, str]]: ... + +class McpCall: + @property + def service(self) -> str: ... + @property + def name(self) -> str: ... + +class NativeMcpPlan: + @property + def commitment(self) -> bytes: ... + @property + def members(self) -> List[bytes]: ... + @property + def permissions(self) -> List[Permission]: ... + @property + def resource_namespaces(self) -> List[str]: ... + @property + def audiences(self) -> List[str]: ... + +class McpCommand: + @property + def action_commitment(self) -> bytes: ... + @property + def authority_commitment(self) -> bytes: ... + @property + def context_commitment(self) -> bytes: ... + @property + def service(self) -> str: ... + @property + def name(self) -> str: ... + +class McpPlanCommand: + @property + def count(self) -> int: ... + @property + def plan_commitment(self) -> bytes: ... + @property + def receipt_bindings(self) -> List[Tuple[bytes, bytes, bytes]]: ... + +class McpGatewayCall: + @property + def service(self) -> str: ... + @property + def name(self) -> str: ... + @property + def arguments_json(self) -> bytes: ... + +class AssurancePolicy: + def __init__( + self, + identifier: str, + requirements: List[Tuple[str, str, str, Optional[int]]], + ) -> None: ... + +class TrustAnchor: + def __init__( + self, + identifier: str, + principal: Principal, + accepted_methods: List[str], + profiles: List[Tuple[str, int]], + permissions: List[Permission], + resource_namespaces: List[str], + audiences: List[str], + not_before: int, + expires_at: int, + budget: Optional[Budget], + max_delegation_depth: int, + assurance_policy: str, + status: Optional[StatusPolicy], + ) -> None: ... + +class StatusSnapshot: + @property + def kind(self) -> str: ... + +class TrustedContext: + @property + def configuration(self) -> bytes: ... + def bind_request( + self, audience: str, challenge: bytes, evaluation_time: int + ) -> TrustedContext: ... + +class VerifiedAction: + pass + +class NativeVerificationResult: + @property + def kind(self) -> Literal["authorized", "denied", "indeterminate"]: ... + @property + def code(self) -> str: ... + @property + def stage( + self, + ) -> Literal["decode", "resolve", "principal-control", "authority", "complete"]: ... + @property + def metrics(self) -> Tuple[int, int, int, int, int, int, int]: ... + @property + def required_configuration(self) -> Optional[bytes]: ... + @property + def local_configuration(self) -> bytes: ... + @property + def result_cbor(self) -> bytes: ... + @property + def action(self) -> Optional[VerifiedAction]: ... + +class IdentityProjection: + @property + def method_id(self) -> str: ... + @property + def identity_id(self) -> str: ... + @property + def suite_id(self) -> str: ... + @property + def public_key(self) -> bytes: ... + @property + def packet_kind(self) -> str: ... + @property + def message(self) -> Optional[bytes]: ... + @property + def signature(self) -> Optional[bytes]: ... + +RelationshipProjection = Tuple[str, str, str, List[Tuple[str, bytes]]] + +class IdentityDescriptorProjection: + @property + def method_id(self) -> str: ... + @property + def identity_id(self) -> str: ... + @property + def method_material(self) -> bytes: ... + @property + def relationships(self) -> List[RelationshipProjection]: ... + +class HttpCall: + @property + def method(self) -> str: ... + @property + def scheme(self) -> str: ... + @property + def authority(self) -> str: ... + @property + def path(self) -> str: ... + +class HttpAction: + @property + def unsigned(self) -> UnsignedObject: ... + @property + def audience(self) -> str: ... + @property + def review_title(self) -> str: ... + @property + def review_fields(self) -> List[Tuple[str, str]]: ... + +class HttpCommand: + @property + def action_commitment(self) -> bytes: ... + @property + def authority_commitment(self) -> bytes: ... + @property + def context_commitment(self) -> bytes: ... + +class HttpPlanCommand: + @property + def count(self) -> int: ... + @property + def plan_commitment(self) -> bytes: ... + @property + def receipt_bindings(self) -> List[Tuple[bytes, bytes, bytes]]: ... + +class NativeHttpPlan: + @property + def commitment(self) -> bytes: ... + @property + def members(self) -> List[bytes]: ... + @property + def permissions(self) -> List[Permission]: ... + @property + def resource_namespaces(self) -> List[str]: ... + @property + def audiences(self) -> List[str]: ... + +class HttpGatewayRequest: + @property + def method(self) -> str: ... + @property + def scheme(self) -> str: ... + @property + def authority(self) -> str: ... + @property + def path(self) -> str: ... + @property + def query(self) -> List[Tuple[str, List[str]]]: ... + @property + def headers(self) -> List[Tuple[str, str]]: ... + @property + def content_type(self) -> Optional[str]: ... + @property + def body_digest(self) -> Optional[str]: ... + +class ApplicationAction: + @property + def profile_id(self) -> str: ... + @property + def profile_version(self) -> int: ... + @property + def media_type(self) -> str: ... + @property + def body(self) -> bytes: ... + @property + def permission(self) -> Permission: ... + @property + def resource_namespace(self) -> str: ... + @property + def audience(self) -> str: ... + @property + def budget(self) -> Optional[Budget]: ... + +class ApplicationActionPreparation: + @property + def unsigned(self) -> UnsignedObject: ... + +class ApplicationCommand: + @property + def action_commitment(self) -> bytes: ... + @property + def authority_commitment(self) -> bytes: ... + @property + def context_commitment(self) -> bytes: ... + @property + def profile_id(self) -> str: ... + @property + def profile_version(self) -> int: ... + +class ApplicationPlanCommand: + @property + def count(self) -> int: ... + @property + def plan_commitment(self) -> bytes: ... + @property + def receipt_bindings(self) -> List[Tuple[bytes, bytes, bytes]]: ... + +class NativeApplicationPlan: + @property + def commitment(self) -> bytes: ... + @property + def members(self) -> List[bytes]: ... + +class ApplicationGatewayCall: + @property + def profile_id(self) -> str: ... + @property + def profile_version(self) -> int: ... + @property + def media_type(self) -> str: ... + @property + def body(self) -> bytes: ... + @property + def permission(self) -> Permission: ... + @property + def resource_namespace(self) -> str: ... + @property + def audience(self) -> str: ... + @property + def budget(self) -> Optional[Budget]: ... + +def native_abi_version() -> int: ... +def approval_policy_reference( + policy_id: str, + evaluator_version: str, + mode: str, + max_uses: int, + expires_in_seconds: int, + requirements: List[str], +) -> ApprovalPolicyReference: ... +def validate_trusted_authority(context: TrustedContext, root: Principal) -> None: ... +def validate_root_authority( + signed: SignedObject, + root: Principal, + subject: PrincipalDescriptor, + profile_id: str, + profile_version: int, +) -> GrantAuthority: ... +def bind_delegated_authority( + signed: SignedObject, + parent: GrantAuthority, + subject: PrincipalDescriptor, + issuer: PrincipalDescriptor, + profile_id: str, + profile_version: int, +) -> GrantAuthority: ... +def plan_child_fields( + parent: GrantAuthority, + subject: PrincipalDescriptor, + permissions: List[Permission], + not_before: int, + expires_at: int, + audiences: List[str], + action_mode: str, + action_digests: List[bytes], + budget_mode: str, + budget: Optional[Budget], + remaining_depth: int, + status_mode: str, + status: Optional[StatusPolicy], + assurance_floor: Optional[str], +) -> GrantPlan: ... +def prepare_signing_transaction( + unsigned: UnsignedObject, + principal: PrincipalDescriptor, + policy: ApprovalPolicyReference, + expires_at: int, +) -> SigningTransaction: ... +def commit_plan_approval( + plan_commitment: bytes, + configuration_digest: bytes, + max_uses: int, + expires_at: int, +) -> bytes: ... +def root_grant(issuer: Principal, request: GrantRequest) -> UnsignedObject: ... +def plan_child(parent: SignedObject, request: GrantRequest) -> GrantPlan: ... +def plan_child_statement( + parent: UnsignedObject, request: GrantRequest +) -> GrantPlan: ... +def grant_request_from_statement(statement: UnsignedObject) -> GrantRequest: ... +def principal_status_statement( + method: str, + principal: Principal, + purpose: str, + state: str, + sequence: int, + observed_at: int, + valid_until: int, + issuer: Principal, + extensions: List[CriticalExtension], +) -> UnsignedObject: ... +def grant_status_statement( + method: str, + grant_id: bytes, + state: str, + sequence: int, + observed_at: int, + valid_until: int, + issuer: Principal, + extensions: List[CriticalExtension], +) -> UnsignedObject: ... +def prepare_signing( + unsigned: UnsignedObject, + principal_method: str, + verification_method: str, + suite: str, +) -> SigningRequest: ... +def prepare_mcp_action( + service: str, + name: str, + arguments_json: bytes, + actor: Principal, + terminal_grant: SignedObject, + challenge: bytes, + evaluation_time: int, +) -> McpAction: ... +def validate_mcp_service(service: str) -> None: ... +def mcp_call(service: str, name: str, arguments_json: bytes) -> McpCall: ... +def review_mcp_call(call: McpCall) -> Tuple[str, List[Tuple[str, str]], bytes]: ... +def commit_mcp_plan(calls: List[McpCall]) -> NativeMcpPlan: ... +def prepare_mcp_call_action( + call: McpCall, + actor: Principal, + terminal_grant: SignedObject, + challenge: bytes, + evaluation_time: int, +) -> McpAction: ... +def authorize_mcp( + prepared: McpAction, + signed_action: SignedObject, + grants: List[SignedObject], + grant_evidence: List[List[Tuple[str, str, bytes]]], + action_evidence: List[Tuple[str, str, bytes]], + context: TrustedContext, +) -> Tuple[NativeVerificationResult, Optional[McpCommand]]: ... +def consume_mcp_command( + command: McpCommand, expected_service: str +) -> McpGatewayCall: ... +def seal_mcp_plan_command( + commands: List[McpCommand], expected_service: str, expected_commitment: bytes +) -> McpPlanCommand: ... +def consume_mcp_plan_command( + command: McpPlanCommand, expected_service: str +) -> List[McpGatewayCall]: ... +def status_snapshot( + kind: str, + identifier: bytes, + observed_at: int, + valid_until: int, + statements: List[SignedObject], + checkpoints: List[bytes], + trust: List[Tuple[str, str, int]], +) -> StatusSnapshot: ... +def compile_trusted_context( + configuration: bytes, + expected_plan: Optional[AuthorizationPlan], + minimum_authorized_branches: int, + minimum_distinct_actors: int, + minimum_distinct_roots: int, + anchors: List[TrustAnchor], + assurance_policy: AssurancePolicy, + principal_status: Optional[StatusSnapshot], + grant_status: Optional[StatusSnapshot], + channel_policy: str, + evidence_types: List[str], + critical_extensions: List[str], +) -> TrustedContext: ... +def self_contained_configuration() -> bytes: ... +def verify_v1( + proof_cbor: bytes, + canonical_action_cbor: bytes, + trusted_context_cbor: bytes, +) -> NativeVerificationResult: ... +def verify_many_v1( + inputs: List[Tuple[bytes, bytes, bytes]], +) -> List[NativeVerificationResult]: ... +def decode_identity_v1(packet: bytes) -> IdentityProjection: ... +def encode_identity_descriptor_v1( + method_id: str, + identity_id: str, + method_material: bytes, + relationships: List[RelationshipProjection], +) -> bytes: ... +def decode_identity_descriptor_v1(packet: bytes) -> IdentityDescriptorProjection: ... +def compact_identity_descriptor_v1(packet: bytes) -> bytes: ... +def identity_descriptor_signing_preimage_v1( + packet: bytes, relationship_id: str, message: bytes +) -> bytes: ... +def encode_public_identity_v1( + method_id: str, + identity_id: str, + suite_id: str, + public_key: bytes, +) -> bytes: ... +def raw_key_identity_v2(suite_id: str, public_key: bytes) -> bytes: ... +def validate_raw_key_identity_v2( + method_id: str, + identity_id: str, + suite_id: str, + public_key: bytes, +) -> None: ... +def identity_signing_preimage_v1( + method_id: str, + identity_id: str, + suite_id: str, + public_key: bytes, + message: bytes, +) -> bytes: ... +def verify_ed25519_preimage_v1( + public_key: bytes, preimage: bytes, signature: bytes +) -> None: ... +def http_call( + method: str, + scheme: str, + authority: str, + path: str, + query: List[Tuple[str, List[str]]], + headers: List[Tuple[str, str]], + content_type: Optional[str], + body_digest: Optional[str], +) -> HttpCall: ... +def review_http_call(call: HttpCall) -> Tuple[str, List[Tuple[str, str]], bytes]: ... +def commit_http_plan(calls: List[HttpCall]) -> NativeHttpPlan: ... +def prepare_http_action( + call: HttpCall, + actor: Principal, + terminal_grant: SignedObject, + challenge: bytes, + evaluation_time: int, +) -> HttpAction: ... +def authorize_http( + prepared: HttpAction, + signed_action: SignedObject, + grants: List[SignedObject], + grant_evidence: List[List[Tuple[str, str, bytes]]], + action_evidence: List[Tuple[str, str, bytes]], + context: TrustedContext, +) -> Tuple[NativeVerificationResult, Optional[HttpCommand]]: ... +def inspect_http_action(action: HttpAction) -> bytes: ... +def consume_http_command(command: HttpCommand, expected_origin: str) -> HttpGatewayRequest: ... +def seal_http_plan_command( + commands: List[HttpCommand], expected_origin: str, expected_commitment: bytes +) -> HttpPlanCommand: ... +def consume_http_plan_command( + command: HttpPlanCommand, expected_origin: str +) -> List[HttpGatewayRequest]: ... +def application_action( + profile_id: str, + profile_version: int, + media_type: str, + body: bytes, + capability: str, + resource: str, + budget: Optional[Budget], + resource_namespace: str, + audience: str, +) -> ApplicationAction: ... +def application_action_commitment_v1(action: ApplicationAction) -> bytes: ... +def commit_application_plan(actions: List[ApplicationAction]) -> NativeApplicationPlan: ... +def prepare_application_action( + action: ApplicationAction, + actor: Principal, + terminal_grant: SignedObject, + challenge: bytes, + evaluation_time: int, +) -> ApplicationActionPreparation: ... +def authorize_application( + prepared: ApplicationActionPreparation, + signed_action: SignedObject, + grants: List[SignedObject], + grant_evidence: List[List[Tuple[str, str, bytes]]], + action_evidence: List[Tuple[str, str, bytes]], + context: TrustedContext, +) -> Tuple[NativeVerificationResult, Optional[ApplicationCommand]]: ... +def seal_application_plan_command( + commands: List[ApplicationCommand], + expected_profile_id: str, + expected_profile_version: int, + expected_commitment: bytes, +) -> ApplicationPlanCommand: ... +def consume_application_command( + command: ApplicationCommand, + expected_profile_id: str, + expected_profile_version: int, +) -> ApplicationGatewayCall: ... +def consume_application_plan_command( + command: ApplicationPlanCommand, + expected_profile_id: str, + expected_profile_version: int, +) -> List[ApplicationGatewayCall]: ... +def runtime_transition_v1( + current: Optional[str], + operation: str, + core_authorized: bool, + policy_eligible: bool, + configuration_matches: bool, + not_revoked: bool, + not_expired: bool, + capacity_available: bool, + execution_intent_present: bool, + credential_authorized: bool, + attempt_present: bool, + provider_call_entered: bool, + cancellation_allowed: bool, + definite_effect: bool, + definite_non_effect: bool, + reconciliation_fresh: bool, + reconciliation_matches: bool, +) -> Tuple[str, Optional[str]]: ... +def runtime_replay_v1(record_exists: bool, commitments_equal: bool) -> str: ... +def runtime_additive_capacity_v1( + ceiling: int, committed: int, active: int, requested: int +) -> bool: ... +def runtime_exclusive_capacity_v1( + has_live_owner: bool, owner_is_exact_replay: bool +) -> bool: ... +def runtime_execution_state_v1(outcome: str) -> str: ... +def decode_diagnostic_result_v1(result_cbor: bytes) -> NativeVerificationResult: ... +def commit_canonical_v1(domain: str, canonical: bytes) -> bytes: ... +def diagnostic_input_limits_v1() -> Tuple[int, int, int]: ... +def commitments_equal_v1(left: bytes, right: bytes) -> bool: ... +def inspect_verified_action(action: VerifiedAction) -> bytes: ... +def inspect_unsigned(value: UnsignedObject) -> bytes: ... +def inspect_signed(value: SignedObject) -> bytes: ... +def inspect_plan(value: AuthorizationPlan) -> bytes: ... +def inspect_mcp_action(value: McpAction) -> Tuple[bytes, bytes]: ... +def inspect_trusted_context(value: TrustedContext) -> bytes: ... +def parse_signed(kind: str, value: bytes) -> SignedObject: ... +def parse_unsigned(kind: str, value: bytes) -> UnsignedObject: ... +def parse_trusted_context(value: bytes) -> TrustedContext: ... +def unsigned_from_signed(value: SignedObject) -> UnsignedObject: ... diff --git a/bindings/python/python/auths/_plan.py b/bindings/python/python/auths/_plan.py new file mode 100644 index 00000000..419e7d75 --- /dev/null +++ b/bindings/python/python/auths/_plan.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import time +from typing import Tuple + +from . import _native as native +from .errors import AuthsWorkflowError, ProviderOperationError +from .workflow import ( + ApprovalConfiguration, + ApprovalProvider, + ApprovalRequest, + ApprovalResponse, + ReviewField, +) + + +class _PlanMemberApproval: + def __init__( + self, session: PlanApprovalSession, index: int, member_commitment: bytes + ) -> None: + self._session = session + self._index = index + self._member_commitment = member_commitment + + async def approve(self, request: ApprovalRequest) -> ApprovalResponse: + return await self._session.approve_member( + self._index, self._member_commitment, request + ) + + +class PlanApprovalSession: + def __init__( + self, + *, + plan_approval: bytes, + member_commitments: Tuple[bytes, ...], + approval: ApprovalConfiguration, + provider: ApprovalProvider, + expires_at: int, + display: Tuple[ReviewField, ...], + ) -> None: + self._plan_approval = bytearray(plan_approval) + self._member_commitments = tuple( + bytearray(value) for value in member_commitments + ) + self._approval = approval + self._provider = provider + self._expires_at = expires_at + self._display = display + self._uses = 0 + self._approved = False + self._disposed = False + + def provider_for(self, index: int, member_commitment: bytes) -> ApprovalProvider: + if self._disposed: + raise AuthsWorkflowError( + "approval-cancelled", "plan approval session is disposed" + ) + if ( + type(index) is not int + or index < 0 + or index >= len(self._member_commitments) + or len(member_commitment) != 32 + or not native.commitments_equal_v1( + bytes(self._member_commitments[index]), member_commitment + ) + ): + raise AuthsWorkflowError( + "approval-response-mismatch", + "approval plan member commitment mismatch", + ) + return _PlanMemberApproval(self, index, bytes(member_commitment)) + + async def approve_member( + self, index: int, member_commitment: bytes, request: ApprovalRequest + ) -> ApprovalResponse: + now = int(time.time()) + if self._disposed: + raise ProviderOperationError("cancelled") + if now > self._expires_at or now > request.expires_at: + raise ProviderOperationError("timeout") + if self._uses >= self._approval.policy.max_uses or index != self._uses: + raise ProviderOperationError("rejected") + if not native.commitments_equal_v1( + bytes(self._member_commitments[index]), member_commitment + ): + raise ProviderOperationError("rejected") + if not request.policy.matches(self._approval.policy.reference): + raise ProviderOperationError("rejected") + if not self._approved: + response = await self._provider.approve( + ApprovalRequest( + request_id=request.request_id, + object_kind=request.object_kind, + transaction_digest=request.transaction_digest, + policy=request.policy, + expires_at=request.expires_at, + display=( + self._display + + ( + ReviewField( + "Plan commitment", bytes(self._plan_approval).hex() + ), + ReviewField( + "Plan member", + f"{index + 1}/{len(self._member_commitments)}", + ), + ReviewField("Member commitment", member_commitment.hex()), + ) + + request.display + ), + ) + ) + if type(response) is not ApprovalResponse: + raise ProviderOperationError("rejected") + if response.decision != "approved": + return response + if ( + response.request_id != request.request_id + or not native.commitments_equal_v1( + response.transaction_digest, request.transaction_digest + ) + or not response.policy.matches(request.policy) + ): + raise ProviderOperationError("rejected") + self._approved = True + self._uses += 1 + return ApprovalResponse( + request.request_id, + request.transaction_digest, + request.policy, + "approved", + ) + + def dispose(self) -> None: + self._disposed = True + for index in range(len(self._plan_approval)): + self._plan_approval[index] = 0 + for member in self._member_commitments: + for index in range(len(member)): + member[index] = 0 diff --git a/bindings/python/python/auths/approvals.py b/bindings/python/python/auths/approvals.py new file mode 100644 index 00000000..5994d2dd --- /dev/null +++ b/bindings/python/python/auths/approvals.py @@ -0,0 +1,83 @@ +"""Approval policy values and provider ports.""" + +from __future__ import annotations + +import asyncio +from typing import Sequence + +from ._native import ApprovalPolicyReference, approval_policy_reference +from .errors import ProviderOperationError +from .workflow import ( + Approval, + ApprovalConfiguration, + ApprovalDecision, + ApprovalMode, + ApprovalPolicy, + ApprovalProvider, + ApprovalRequest, + ApprovalResponse, +) + + +class ThresholdApprovalProvider: + def __init__( + self, providers: Sequence[ApprovalProvider], *, threshold: int + ) -> None: + values = tuple(providers) + if ( + type(threshold) is not int + or threshold < 1 + or threshold > len(values) + or len(values) > 16 + or len({id(value) for value in values}) != len(values) + ): + raise ValueError("invalid threshold approval configuration") + self._providers = values + self._threshold = threshold + + async def approve(self, request: ApprovalRequest) -> ApprovalResponse: + results = await asyncio.gather( + *(provider.approve(request) for provider in self._providers), + return_exceptions=True, + ) + approved = 0 + for result in results: + if isinstance(result, BaseException): + continue + if type(result) is not ApprovalResponse: + raise ProviderOperationError("rejected") + if ( + result.request_id != request.request_id + or result.transaction_digest != request.transaction_digest + or not result.policy.matches(request.policy) + ): + raise ProviderOperationError("rejected") + if result.decision == "approved": + approved += 1 + return ApprovalResponse( + request.request_id, + request.transaction_digest, + request.policy, + "approved" if approved >= self._threshold else "rejected", + ) + + +def threshold_approval( + providers: Sequence[ApprovalProvider], *, threshold: int +) -> ApprovalProvider: + return ThresholdApprovalProvider(providers, threshold=threshold) + +__all__ = [ + "Approval", + "ApprovalConfiguration", + "ApprovalDecision", + "ApprovalMode", + "ApprovalPolicy", + "ApprovalPolicyReference", + "ApprovalProvider", + "ApprovalRequest", + "ApprovalResponse", + "ThresholdApprovalProvider", + "approval_policy_reference", + "threshold_approval", +] diff --git a/bindings/python/python/auths/authority.py b/bindings/python/python/auths/authority.py new file mode 100644 index 00000000..687e3716 --- /dev/null +++ b/bindings/python/python/auths/authority.py @@ -0,0 +1,210 @@ +"""Typed authority authoring, proof plans, and attenuation.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, Sequence, Tuple + +from ._native import ( + AuthorityDiff, + AuthorizationPlan as _NativeAuthorizationPlan, + AuthorizationPlanBuilder as _NativeAuthorizationPlanBuilder, + GrantAuthority, + GrantPlan, + GrantRequest, + Principal, + PrincipalDescriptor, + SignedObject, + UnsignedObject, + bind_delegated_authority, + grant_request_from_statement, + plan_child, + plan_child_fields, + plan_child_statement, + root_grant, + validate_root_authority, + validate_trusted_authority, +) +from ._native import inspect_plan as _inspect_plan +from .workflow import ( + AllowedBodies, + AnyBody, + BudgetCeiling, + DelegatedAuthority, + DelegationReview, + ExactBody, + ExpiryOnly, + InheritAction, + InheritBudget, + InheritStatus, + NoBudget, + Permission, + SignedGrantInput, + SignedGrantLoadRequest, + SignedGrantMaterial, + SignedGrantProvider, + SignedGrantSource, + SnapshotRequired, + Validity, +) + +ProofPlanKind = Literal["proof", "all-of", "any-of", "threshold"] +_PLAN_TOKEN = object() + + +@dataclass(frozen=True) +class ProofReference: + bytes: bytes + + def __post_init__(self) -> None: + value = bytes(self.bytes) + if len(value) != 32: + raise ValueError("proof reference must contain 32 bytes") + object.__setattr__(self, "bytes", value) + + @classmethod + def parse(cls, value: str) -> ProofReference: + if len(value) != 64 or any(character not in "0123456789abcdef" for character in value): + raise ValueError("proof reference must be 64 lowercase hexadecimal characters") + return cls(bytes.fromhex(value)) + + +class ProofPlan: + def __init__( + self, + token: object, + owner: ProofPlanBuilder, + kind: ProofPlanKind, + native: _NativeAuthorizationPlan, + references: Tuple[ProofReference, ...], + ) -> None: + if token is not _PLAN_TOKEN: + raise TypeError("sealed Auths proof plan") + self._owner = owner + self._native = native + self._references = references + self.kind = kind + + @property + def plan_id(self) -> bytes: + return bytes(self._native.plan_id) + + @property + def leaf_count(self) -> int: + return self._native.shape[0] + + @property + def maximum_depth(self) -> int: + return self._native.shape[1] + + @property + def proof_references(self) -> Tuple[ProofReference, ...]: + return self._references + + def canonical_bytes(self) -> bytes: + return bytes(_inspect_plan(self._native)) + + +class ProofPlanBuilder: + def __init__(self) -> None: + self._native = _NativeAuthorizationPlanBuilder() + + def proof(self, reference: ProofReference) -> ProofPlan: + if type(reference) is not ProofReference: + raise TypeError("proof plan requires a ProofReference") + return ProofPlan( + _PLAN_TOKEN, + self, + "proof", + self._native.proof(reference.bytes), + (reference,), + ) + + def all_of(self, members: Sequence[ProofPlan]) -> ProofPlan: + return self._compound("all-of", members) + + def any_of(self, members: Sequence[ProofPlan]) -> ProofPlan: + return self._compound("any-of", members) + + def threshold(self, required: int, members: Sequence[ProofPlan]) -> ProofPlan: + values = self._members(members) + native = self._native.threshold(required, [value._native for value in values]) + return ProofPlan( + _PLAN_TOKEN, + self, + "threshold", + native, + tuple(reference for value in values for reference in value._references), + ) + + def _compound( + self, kind: Literal["all-of", "any-of"], members: Sequence[ProofPlan] + ) -> ProofPlan: + values = self._members(members) + native_members = [value._native for value in values] + native = ( + self._native.all_of(native_members) + if kind == "all-of" + else self._native.any_of(native_members) + ) + return ProofPlan( + _PLAN_TOKEN, + self, + kind, + native, + tuple(reference for value in values for reference in value._references), + ) + + def _members(self, members: Sequence[ProofPlan]) -> Tuple[ProofPlan, ...]: + values = tuple(members) + if any(type(value) is not ProofPlan or value._owner is not self for value in values): + raise ValueError("proof plan member belongs to another builder") + return values + + +def _native_proof_plan(plan: ProofPlan) -> _NativeAuthorizationPlan: + if type(plan) is not ProofPlan: + raise TypeError("expected a sealed Auths proof plan") + return plan._native + +__all__ = [ + "AllowedBodies", + "AnyBody", + "AuthorityDiff", + "BudgetCeiling", + "DelegatedAuthority", + "DelegationReview", + "ExactBody", + "ExpiryOnly", + "GrantAuthority", + "GrantPlan", + "GrantRequest", + "InheritAction", + "InheritBudget", + "InheritStatus", + "NoBudget", + "Permission", + "ProofPlan", + "ProofPlanBuilder", + "ProofPlanKind", + "ProofReference", + "Principal", + "PrincipalDescriptor", + "SignedGrantInput", + "SignedGrantLoadRequest", + "SignedGrantMaterial", + "SignedGrantProvider", + "SignedGrantSource", + "SignedObject", + "SnapshotRequired", + "UnsignedObject", + "Validity", + "bind_delegated_authority", + "grant_request_from_statement", + "plan_child", + "plan_child_fields", + "plan_child_statement", + "root_grant", + "validate_root_authority", + "validate_trusted_authority", +] diff --git a/bindings/python/python/auths/custody.py b/bindings/python/python/auths/custody.py new file mode 100644 index 00000000..8c967a41 --- /dev/null +++ b/bindings/python/python/auths/custody.py @@ -0,0 +1,25 @@ +"""Signing requests, responses, and custody provider ports.""" + +from ._native import PrincipalDescriptor +from .workflow import ( + ControlEvidence, + ProviderFailureKind, + ProviderOperationError, + Signer, + SignerLifecycle, + SigningObjectKind, + SigningRequest, + SigningResponse, +) + +__all__ = [ + "ControlEvidence", + "PrincipalDescriptor", + "ProviderFailureKind", + "ProviderOperationError", + "Signer", + "SignerLifecycle", + "SigningObjectKind", + "SigningRequest", + "SigningResponse", +] diff --git a/bindings/python/python/auths/diagnostics.py b/bindings/python/python/auths/diagnostics.py new file mode 100644 index 00000000..4967eb57 --- /dev/null +++ b/bindings/python/python/auths/diagnostics.py @@ -0,0 +1,165 @@ +"""Inert verification diagnostics for caller-supplied engines.""" + +from __future__ import annotations + +import importlib.metadata +from dataclasses import dataclass +from typing import Literal, Mapping, Optional, Protocol, Tuple, runtime_checkable + +from ._native import ( + decode_diagnostic_result_v1, + diagnostic_input_limits_v1, + native_abi_version, +) +from .inspection import InspectionMetrics, VerificationStage, VerdictKind + + +@runtime_checkable +class DiagnosticEngine(Protocol): + def verify_v1( + self, + proof_cbor: bytes, + canonical_action_cbor: bytes, + trusted_context_cbor: bytes, + ) -> bytes: ... + + +@dataclass(frozen=True) +class DiagnosticExplanation: + code: str + message: str + retryable: bool + + +@dataclass(frozen=True) +class DiagnosticResult: + effect_capable: Literal[False] + kind: VerdictKind + code: str + stage: VerificationStage + explanation: DiagnosticExplanation + metrics: InspectionMetrics + required_configuration: Optional[bytes] + local_configuration: bytes + result_cbor: bytes + submitted_action_cbor: bytes + + +@dataclass(frozen=True) +class RuntimeDiagnostic: + package_version: str + native_abi: int + required_native_abi: int + coherent: bool + capabilities: Tuple[str, ...] + profiles: Tuple[str, ...] + trust_configuration: Optional[bytes] + adapters: Tuple[Tuple[str, int], ...] + + +class DiagnosticVerifier: + def __init__(self, engine: DiagnosticEngine) -> None: + if not callable(getattr(engine, "verify_v1", None)): + raise TypeError("diagnostic engine must expose verify_v1") + self._engine = engine + + def verify( + self, + proof_cbor: bytes, + canonical_action_cbor: bytes, + trusted_context_cbor: bytes, + ) -> DiagnosticResult: + proof = _bounded_bytes(proof_cbor, 0) + action = _bounded_bytes(canonical_action_cbor, 1) + context = _bounded_bytes(trusted_context_cbor, 2) + try: + encoded = self._engine.verify_v1(proof, action, context) + except Exception: + raise ValueError("diagnostic engine failed") from None + if type(encoded) is not bytes: + raise TypeError("diagnostic engine returned a non-byte result") + try: + native = decode_diagnostic_result_v1(encoded) + except (TypeError, ValueError, RuntimeError): + raise ValueError("diagnostic engine returned an invalid result") from None + return DiagnosticResult( + effect_capable=False, + kind=native.kind, + code=native.code, + stage=native.stage, + explanation=_explanation(native.kind, native.code), + metrics=InspectionMetrics(*native.metrics), + required_configuration=native.required_configuration, + local_configuration=bytes(native.local_configuration), + result_cbor=bytes(native.result_cbor), + submitted_action_cbor=action, + ) + + +def create_diagnostic_verifier(engine: DiagnosticEngine) -> DiagnosticVerifier: + return DiagnosticVerifier(engine) + + +def runtime_diagnostic( + *, + trust_configuration: Optional[bytes] = None, + adapters: Optional[Mapping[str, int]] = None, +) -> RuntimeDiagnostic: + try: + version = importlib.metadata.version("auths") + except importlib.metadata.PackageNotFoundError: + version = "source-tree" + native_abi = native_abi_version() + required = 2 + trust = None if trust_configuration is None else bytes(trust_configuration) + if trust is not None and len(trust) != 32: + raise ValueError("trust configuration commitment must contain 32 bytes") + adapter_values = tuple(sorted((adapters or {}).items())) + if any(not name or type(version) is not int or version < 1 for name, version in adapter_values): + raise ValueError("adapter contract declarations are invalid") + return RuntimeDiagnostic( + package_version=version, + native_abi=native_abi, + required_native_abi=required, + coherent=native_abi == required, + capabilities=( + "identity", + "verification", + "authority", + "delegation", + "plans", + "runtime-state", + ), + profiles=("auths.mcp/1", "auths.http/1"), + trust_configuration=trust, + adapters=adapter_values, + ) + + +def _bounded_bytes(value: bytes, index: int) -> bytes: + if type(value) is not bytes: + raise TypeError("diagnostic verifier inputs must be bytes") + limits = diagnostic_input_limits_v1() + if not value or len(value) > limits[index]: + raise ValueError("diagnostic verifier input is outside native limits") + return bytes(value) + + +def _explanation(kind: VerdictKind, code: str) -> DiagnosticExplanation: + messages = { + "authorized": "the diagnostic engine reported authority for this action", + "denied": "the diagnostic engine reported that authority was not established", + "indeterminate": "the diagnostic engine reported that a required fact was unavailable", + } + return DiagnosticExplanation(code, messages[kind], kind == "indeterminate") + + +__all__ = [ + "DiagnosticEngine", + "DiagnosticExplanation", + "DiagnosticResult", + "DiagnosticVerifier", + "RuntimeDiagnostic", + "create_diagnostic_verifier", + "runtime_diagnostic", +] diff --git a/bindings/python/python/auths/errors.py b/bindings/python/python/auths/errors.py new file mode 100644 index 00000000..854ef593 --- /dev/null +++ b/bindings/python/python/auths/errors.py @@ -0,0 +1,137 @@ +"""Stable, redacted Auths SDK failures.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal, Optional, Tuple + +RetryClass = Literal["never", "safe", "conditional", "unknown"] +EffectState = Literal[ + "not-started", "in-progress", "completed", "failed", "outcome-unknown" +] +ProviderFailureKind = Literal[ + "unavailable", "rejected", "cancelled", "timeout", "unsupported" +] + + +@dataclass(frozen=True) +class ErrorDetails: + family: str + code: str + operation: str + stage: str + correlation_id: Optional[str] + retry: RetryClass + effect_state: EffectState + remediation: str + cause_codes: Tuple[str, ...] + + +class AuthsError(Exception): + def __init__(self, message: str, details: ErrorDetails) -> None: + super().__init__(message) + self.details = details + self.family = details.family + self.code = details.code + self.operation = details.operation + self.stage = details.stage + self.correlation_id = details.correlation_id + self.retry = details.retry + self.effect_state = details.effect_state + self.remediation = details.remediation + self.cause_codes = details.cause_codes + + def __repr__(self) -> str: + return ( + f"{type(self).__name__}(family={self.family!r}, code={self.code!r}, " + f"operation={self.operation!r}, stage={self.stage!r}, " + f"retry={self.retry!r}, effect_state={self.effect_state!r})" + ) + + +class AuthsWorkflowError(AuthsError): + def __init__( + self, + code: str, + message: str, + *, + operation: str = "workflow", + stage: str = "coordinate", + retry: RetryClass = "never", + effect_state: EffectState = "not-started", + remediation: str = "inspect the typed workflow input and retry only if corrected", + correlation_id: Optional[str] = None, + cause_codes: Tuple[str, ...] = (), + ) -> None: + super().__init__( + message, + ErrorDetails( + "workflow", + code, + operation, + stage, + correlation_id, + retry, + effect_state, + remediation, + tuple(cause_codes), + ), + ) + + +class ProviderOperationError(AuthsError): + def __init__(self, kind: ProviderFailureKind) -> None: + if kind not in ( + "unavailable", + "rejected", + "cancelled", + "timeout", + "unsupported", + ): + raise ValueError("unsupported provider failure kind") + retry: RetryClass = "safe" if kind in ("unavailable", "timeout") else "never" + super().__init__( + "external provider operation failed", + ErrorDetails( + "provider", + kind, + "provider-callback", + "provider", + None, + retry, + "not-started", + "inspect the provider health and its conformance result", + (), + ), + ) + self.kind: ProviderFailureKind = kind + + +class RuntimeStateError(AuthsError): + def __init__(self, code: str, *, retry: RetryClass, effect_state: EffectState) -> None: + super().__init__( + "runtime state transition failed", + ErrorDetails( + "runtime", + code, + "execute", + "state", + None, + retry, + effect_state, + "reconcile the command state before attempting another effect", + (), + ), + ) + + +__all__ = [ + "AuthsError", + "AuthsWorkflowError", + "EffectState", + "ErrorDetails", + "ProviderFailureKind", + "ProviderOperationError", + "RetryClass", + "RuntimeStateError", +] diff --git a/bindings/python/python/auths/identity.py b/bindings/python/python/auths/identity.py new file mode 100644 index 00000000..f53d03b9 --- /dev/null +++ b/bindings/python/python/auths/identity.py @@ -0,0 +1,479 @@ +"""Transport- and authority-independent identity and authentication.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from types import MappingProxyType +from typing import ( + Awaitable, + Mapping, + Protocol, + Sequence, + Tuple, + TypeVar, + runtime_checkable, +) + +from ._native import ( + compact_identity_descriptor_v1, + decode_identity_descriptor_v1, + encode_identity_descriptor_v1, + identity_descriptor_signing_preimage_v1, + raw_key_identity_v2, + validate_raw_key_identity_v2, + verify_ed25519_preimage_v1, +) + +MAX_IDENTITY_TIMEOUT = 300.0 +ValueT = TypeVar("ValueT") + + +@dataclass(frozen=True) +class VerificationMaterial: + material_id: str + bytes: bytes + + def __post_init__(self) -> None: + value = bytes(self.bytes) + if not self.material_id or not value or len(value) > 128 * 1024: + raise ValueError("verification material is outside supported bounds") + object.__setattr__(self, "bytes", value) + + +@dataclass(frozen=True) +class VerificationRelationship: + relationship_id: str + purpose: str + suite_id: str + verification_material: Tuple[VerificationMaterial, ...] + + def __post_init__(self) -> None: + values = tuple(self.verification_material) + if not values or any(type(value) is not VerificationMaterial for value in values): + raise ValueError("verification relationship requires typed material") + object.__setattr__(self, "verification_material", values) + + +@dataclass(frozen=True) +class ResolutionEvidence: + source: str + observed_at: int + expires_at: int + provenance: Tuple[str, ...] + history: Tuple[str, ...] = () + + def __post_init__(self) -> None: + if not self.source or self.observed_at < 0 or self.expires_at < self.observed_at: + raise ValueError("invalid identity resolution evidence") + object.__setattr__(self, "provenance", tuple(self.provenance)) + object.__setattr__(self, "history", tuple(self.history)) + + +@dataclass(frozen=True) +class ResolvedIdentityRecord: + method_id: str + identity_id: str + method_material: bytes + relationships: Tuple[VerificationRelationship, ...] + evidence: ResolutionEvidence + + def __post_init__(self) -> None: + object.__setattr__(self, "method_material", bytes(self.method_material)) + values = tuple(self.relationships) + if not values or any(type(value) is not VerificationRelationship for value in values): + raise ValueError("resolved identity has no typed verification relationships") + object.__setattr__(self, "relationships", values) + + def relationship(self, relationship_id: str) -> VerificationRelationship: + values = tuple( + value for value in self.relationships if value.relationship_id == relationship_id + ) + if len(values) != 1: + raise ValueError("identity relationship is missing or ambiguous") + return values[0] + + def canonical_bytes(self) -> bytes: + return _encode_descriptor( + self.method_id, + self.identity_id, + self.method_material, + self.relationships, + ) + + +@runtime_checkable +class IdentityResolver(Protocol): + async def resolve( + self, method_id: str, identity_id: str, *, maximum_bytes: int + ) -> ResolvedIdentityRecord: ... + + +@runtime_checkable +class IdentityMethod(Protocol): + method_id: str + version: int + + async def resolve(self, identity: DecodedIdentity) -> ResolvedIdentityRecord: ... + + async def validate(self, identity: ResolvedIdentity) -> None: ... + + +@runtime_checkable +class SignatureSuite(Protocol): + suite_id: str + version: int + + async def verify( + self, + material: Tuple[VerificationMaterial, ...], + preimage: bytes, + signature: bytes, + ) -> None: ... + + +@dataclass(frozen=True) +class DecodedIdentity: + method_id: str + identity_id: str + method_material: bytes + relationships: Tuple[VerificationRelationship, ...] + packet: bytes + + def relationship(self, relationship_id: str) -> VerificationRelationship: + return self._record().relationship(relationship_id) + + async def resolve( + self, + registry: IdentityRegistry, + *, + timeout: float = 10.0, + ) -> ResolvedIdentity: + method = registry.method(self.method_id) + record = await _bounded_await(method.resolve(self), timeout) + if record.method_id != self.method_id or record.identity_id != self.identity_id: + raise ValueError("identity resolver returned a different identity") + return ResolvedIdentity(self, record) + + async def validate( + self, + registry: IdentityRegistry, + *, + timeout: float = 10.0, + ) -> ValidatedIdentity: + resolved = await self.resolve(registry, timeout=timeout) + return await resolved.validate(registry, timeout=timeout) + + def _record(self) -> ResolvedIdentityRecord: + return ResolvedIdentityRecord( + self.method_id, + self.identity_id, + self.method_material, + self.relationships, + ResolutionEvidence("packet", 0, (1 << 64) - 1, ("packet",)), + ) + + +@dataclass(frozen=True) +class ResolvedIdentity: + decoded: DecodedIdentity + record: ResolvedIdentityRecord + + @property + def identity_id(self) -> str: + return self.record.identity_id + + @property + def evidence(self) -> ResolutionEvidence: + return self.record.evidence + + async def validate( + self, + registry: IdentityRegistry, + *, + timeout: float = 10.0, + ) -> ValidatedIdentity: + method = registry.method(self.record.method_id) + await _bounded_await(method.validate(self), timeout) + return ValidatedIdentity(self) + + +@dataclass(frozen=True) +class ValidatedIdentity: + resolved: ResolvedIdentity + + @property + def identity_id(self) -> str: + return self.resolved.identity_id + + async def authenticate( + self, + message: bytes, + signature: bytes, + registry: IdentityRegistry, + *, + relationship_id: str = "default-signing", + timeout: float = 10.0, + ) -> AuthenticatedIdentity: + message_bytes = bytes(message) + signature_bytes = bytes(signature) + relationship = self.resolved.record.relationship(relationship_id) + preimage = bytes( + identity_descriptor_signing_preimage_v1( + self.resolved.record.canonical_bytes(), + relationship_id, + message_bytes, + ) + ) + suite = registry.suite(relationship.suite_id) + await _bounded_await( + suite.verify( + relationship.verification_material, preimage, signature_bytes + ), + timeout, + ) + return AuthenticatedIdentity( + self, relationship_id, message_bytes, signature_bytes + ) + + def authority_input( + self, *, relationship_id: str = "default-signing", assurance: str + ) -> IdentityPrincipal: + if not assurance: + raise ValueError("identity assurance cannot be empty") + relationship = self.resolved.record.relationship(relationship_id) + return IdentityPrincipal( + principal_id=self.resolved.record.identity_id, + method_id=self.resolved.record.method_id, + relationship_id=relationship.relationship_id, + suite_id=relationship.suite_id, + purpose=relationship.purpose, + provenance=self.resolved.evidence.provenance, + assurance=assurance, + ) + + +@dataclass(frozen=True) +class AuthenticatedIdentity: + validated: ValidatedIdentity + relationship_id: str + message: bytes + signature: bytes + + @property + def identity_id(self) -> str: + return self.validated.identity_id + + +@dataclass(frozen=True) +class IdentityPrincipal: + principal_id: str + method_id: str + relationship_id: str + suite_id: str + purpose: str + provenance: Tuple[str, ...] + assurance: str + + +class IdentityRegistry: + def __init__( + self, + *, + methods: Sequence[IdentityMethod], + suites: Sequence[SignatureSuite], + ) -> None: + method_map = _exact_registry(methods, "method_id", "identity method") + suite_map = _exact_registry(suites, "suite_id", "signature suite") + self._methods: Mapping[str, IdentityMethod] = MappingProxyType(method_map) + self._suites: Mapping[str, SignatureSuite] = MappingProxyType(suite_map) + + def method(self, method_id: str) -> IdentityMethod: + try: + return self._methods[method_id] + except KeyError: + raise ValueError("unsupported identity method") from None + + def suite(self, suite_id: str) -> SignatureSuite: + try: + return self._suites[suite_id] + except KeyError: + raise ValueError("unsupported signature suite") from None + + +class RawKeyIdentityMethod: + method_id = "raw-key-v2" + version = 2 + + async def resolve(self, identity: DecodedIdentity) -> ResolvedIdentityRecord: + return identity._record() + + async def validate(self, identity: ResolvedIdentity) -> None: + record = identity.record + relationship = record.relationship("default-signing") + if relationship.purpose != "authentication" or len(relationship.verification_material) != 1: + raise ValueError("raw-key identity has an invalid verification relationship") + validate_raw_key_identity_v2( + record.method_id, + record.identity_id, + relationship.suite_id, + relationship.verification_material[0].bytes, + ) + + +class ResolverIdentityMethod: + def __init__( + self, + method_id: str, + resolver: IdentityResolver, + *, + version: int = 1, + maximum_bytes: int = 128 * 1024, + ) -> None: + if not method_id or version < 1 or maximum_bytes < 1: + raise ValueError("invalid resolver identity method") + self.method_id = method_id + self.version = version + self._resolver = resolver + self._maximum_bytes = maximum_bytes + + async def resolve(self, identity: DecodedIdentity) -> ResolvedIdentityRecord: + return await self._resolver.resolve( + self.method_id, + identity.identity_id, + maximum_bytes=self._maximum_bytes, + ) + + async def validate(self, identity: ResolvedIdentity) -> None: + if identity.evidence.expires_at < identity.evidence.observed_at: + raise ValueError("resolved identity evidence is invalid") + + +class Ed25519SignatureSuite: + suite_id = "ed25519-v1" + version = 1 + + async def verify( + self, + material: Tuple[VerificationMaterial, ...], + preimage: bytes, + signature: bytes, + ) -> None: + if len(material) != 1: + raise ValueError("Ed25519 requires one verification material object") + verify_ed25519_preimage_v1(material[0].bytes, preimage, signature) + + +def decode_identity(packet: bytes) -> DecodedIdentity: + packet_bytes = bytes(packet) + try: + native = decode_identity_descriptor_v1(packet_bytes) + except ValueError: + packet_bytes = bytes(compact_identity_descriptor_v1(packet_bytes)) + native = decode_identity_descriptor_v1(packet_bytes) + relationships = tuple( + VerificationRelationship( + relationship_id, + purpose, + suite_id, + tuple( + VerificationMaterial(material_id, material) + for material_id, material in materials + ), + ) + for relationship_id, purpose, suite_id, materials in native.relationships + ) + return DecodedIdentity( + native.method_id, + native.identity_id, + bytes(native.method_material), + relationships, + packet_bytes, + ) + + +def encode_identity( + method_id: str, + identity_id: str, + *, + method_material: bytes = b"", + relationships: Sequence[VerificationRelationship], +) -> bytes: + return _encode_descriptor( + method_id, identity_id, bytes(method_material), tuple(relationships) + ) + + +def encode_raw_key_identity(suite_id: str, public_key: bytes) -> bytes: + return bytes(raw_key_identity_v2(suite_id, public_key)) + + +def _encode_descriptor( + method_id: str, + identity_id: str, + method_material: bytes, + relationships: Sequence[VerificationRelationship], +) -> bytes: + return bytes( + encode_identity_descriptor_v1( + method_id, + identity_id, + method_material, + [ + ( + value.relationship_id, + value.purpose, + value.suite_id, + [ + (material.material_id, material.bytes) + for material in value.verification_material + ], + ) + for value in relationships + ], + ) + ) + + +async def _bounded_await(value: Awaitable[ValueT], timeout: float) -> ValueT: + if timeout <= 0 or timeout > MAX_IDENTITY_TIMEOUT: + raise ValueError("identity timeout is outside supported limits") + return await asyncio.wait_for(value, timeout=timeout) + + +def _exact_registry( + values: Sequence[ValueT], attribute: str, label: str +) -> dict[str, ValueT]: + result: dict[str, ValueT] = {} + for value in values: + identifier = getattr(value, attribute, None) + version = getattr(value, "version", None) + if not isinstance(identifier, str) or not identifier or type(version) is not int: + raise TypeError(label + " does not declare an exact identifier and version") + if identifier in result: + raise ValueError("duplicate " + label) + result[identifier] = value + return result + + +__all__ = [ + "AuthenticatedIdentity", + "DecodedIdentity", + "Ed25519SignatureSuite", + "IdentityMethod", + "IdentityPrincipal", + "IdentityRegistry", + "IdentityResolver", + "RawKeyIdentityMethod", + "ResolutionEvidence", + "ResolvedIdentity", + "ResolvedIdentityRecord", + "ResolverIdentityMethod", + "SignatureSuite", + "ValidatedIdentity", + "VerificationMaterial", + "VerificationRelationship", + "decode_identity", + "encode_identity", + "encode_raw_key_identity", +] diff --git a/bindings/python/python/auths/inspection.py b/bindings/python/python/auths/inspection.py new file mode 100644 index 00000000..daf71ec9 --- /dev/null +++ b/bindings/python/python/auths/inspection.py @@ -0,0 +1,274 @@ +"""Bounded, non-effect-capable Auths decision inspection.""" + +from __future__ import annotations + +from dataclasses import dataclass +from types import MappingProxyType +from typing import ( + Literal, + Mapping, + Optional, + Protocol, + Tuple, + Union, + cast, +) + +from ._native import ( + AuthorizationPlan, + McpAction, + SignedObject, + TrustedContext, + UnsignedObject, + VerifiedAction, + commit_canonical_v1, + inspect_mcp_action, + inspect_plan, + inspect_signed, + inspect_trusted_context, + inspect_unsigned, + inspect_verified_action, + parse_signed, + parse_trusted_context, + parse_unsigned, + unsigned_from_signed, +) + +VerdictKind = Literal["authorized", "denied", "indeterminate"] +VerificationStage = Literal[ + "decode", "resolve", "principal-control", "authority", "complete" +] +SafeLogValue = Union[str, bool] + + +@dataclass(frozen=True) +class InspectionMetrics: + proof_bytes: int + action_bytes: int + context_bytes: int + object_count: int + plan_leaves: int + plan_depth: int + work_units: int + + +@dataclass(frozen=True) +class DecisionSummary: + kind: VerdictKind + + +@dataclass(frozen=True) +class KernelSummary: + stage: VerificationStage + code: str + + +@dataclass(frozen=True) +class DecisionCommitments: + result: bytes + local_configuration: bytes + required_configuration: Optional[bytes] + action: Optional[bytes] + + +@dataclass(frozen=True) +class ApprovalInspection: + policy_id: str + evaluator_version: str + required_configuration: bytes + executed_configuration: bytes + executed_mode: str + executed_max_uses: int + executed_expires_in_seconds: int + executed_requirements: Tuple[str, ...] + + +@dataclass(frozen=True) +class DecisionInspection: + decision: DecisionSummary + kernel: KernelSummary + commitments: DecisionCommitments + metrics: InspectionMetrics + approval: Optional[ApprovalInspection] + safe_to_log: Mapping[str, SafeLogValue] + + +class _Metrics(Protocol): + proof_bytes: int + action_bytes: int + context_bytes: int + object_count: int + plan_leaves: int + plan_depth: int + work_units: int + + +class _Explanation(Protocol): + retryable: bool + + +class InspectableDecision(Protocol): + kind: VerdictKind + code: str + stage: VerificationStage + explanation: _Explanation + metrics: _Metrics + required_configuration: Optional[bytes] + local_configuration: bytes + result_cbor: bytes + + +class _ApprovalSummary(Protocol): + policy_id: str + evaluator_version: str + required_configuration: bytes + executed_configuration: bytes + executed_mode: str + executed_max_uses: int + executed_expires_in_seconds: int + executed_requirements: Tuple[str, ...] + + +def inspect_decision(result: InspectableDecision) -> DecisionInspection: + if result.kind not in ("authorized", "denied", "indeterminate"): + raise TypeError("decision is not an Auths verification result") + metrics = result.metrics + inspection_metrics = InspectionMetrics( + metrics.proof_bytes, + metrics.action_bytes, + metrics.context_bytes, + metrics.object_count, + metrics.plan_leaves, + metrics.plan_depth, + metrics.work_units, + ) + action_commitment: Optional[bytes] = None + action = getattr(result, "action", None) + if type(action) is VerifiedAction: + action_commitment = bytes( + commit_canonical_v1( + "auths.canonical-action.v1", inspect_verified_action(action) + ) + ) + supplied_action_commitment = getattr(result, "action_commitment", None) + if supplied_action_commitment is not None: + supplied_action = bytes(supplied_action_commitment) + if len(supplied_action) != 32: + raise TypeError("decision contains an invalid action commitment") + action_commitment = supplied_action + required = result.required_configuration + approval = _approval_inspection(getattr(result, "approval", None)) + return DecisionInspection( + decision=DecisionSummary(result.kind), + kernel=KernelSummary(result.stage, result.code), + commitments=DecisionCommitments( + result=bytes( + commit_canonical_v1("auths.verification-result.v1", result.result_cbor) + ), + local_configuration=bytes( + commit_canonical_v1( + "auths.verifier-configuration.v1", + result.local_configuration, + ) + ), + required_configuration=( + None + if required is None + else bytes( + commit_canonical_v1("auths.required-configuration.v1", required) + ) + ), + action=action_commitment, + ), + metrics=inspection_metrics, + approval=approval, + safe_to_log=MappingProxyType( + { + "kind": result.kind, + "stage": result.stage, + "code": result.code, + "retryable": result.explanation.retryable, + } + ), + ) + + +def canonical_action_bytes(action: VerifiedAction) -> bytes: + return inspect_verified_action(action) + + +def unsigned_object_bytes(value: UnsignedObject) -> bytes: + return inspect_unsigned(value) + + +def signed_object_bytes(value: SignedObject) -> bytes: + return inspect_signed(value) + + +def authorization_plan_bytes(value: AuthorizationPlan) -> bytes: + return inspect_plan(value) + + +def mcp_action_bytes(value: McpAction) -> Tuple[bytes, bytes]: + return inspect_mcp_action(value) + + +def trusted_context_bytes(value: TrustedContext) -> bytes: + return inspect_trusted_context(value) + + +def parse_signed_object(kind: str, value: bytes) -> SignedObject: + return parse_signed(kind, value) + + +def parse_unsigned_object(kind: str, value: bytes) -> UnsignedObject: + return parse_unsigned(kind, value) + + +def parse_trusted_context_bytes(value: bytes) -> TrustedContext: + return parse_trusted_context(value) + + +def signed_object_statement(value: SignedObject) -> UnsignedObject: + return unsigned_from_signed(value) + + +def _approval_inspection(value: object) -> Optional[ApprovalInspection]: + if value is None: + return None + summary = cast(_ApprovalSummary, value) + try: + return ApprovalInspection( + policy_id=summary.policy_id, + evaluator_version=summary.evaluator_version, + required_configuration=bytes(summary.required_configuration), + executed_configuration=bytes(summary.executed_configuration), + executed_mode=summary.executed_mode, + executed_max_uses=summary.executed_max_uses, + executed_expires_in_seconds=summary.executed_expires_in_seconds, + executed_requirements=tuple(summary.executed_requirements), + ) + except (AttributeError, TypeError, ValueError): + raise TypeError("decision contains an invalid approval summary") from None + + +__all__ = [ + "ApprovalInspection", + "DecisionCommitments", + "DecisionInspection", + "DecisionSummary", + "InspectableDecision", + "InspectionMetrics", + "KernelSummary", + "authorization_plan_bytes", + "canonical_action_bytes", + "inspect_decision", + "mcp_action_bytes", + "parse_signed_object", + "parse_trusted_context_bytes", + "parse_unsigned_object", + "signed_object_bytes", + "signed_object_statement", + "trusted_context_bytes", + "unsigned_object_bytes", +] diff --git a/bindings/python/python/auths/integrations.py b/bindings/python/python/auths/integrations.py new file mode 100644 index 00000000..41dab60e --- /dev/null +++ b/bindings/python/python/auths/integrations.py @@ -0,0 +1,46 @@ +"""Transport and framework adapter boundaries without Auths semantics.""" + +from __future__ import annotations + +import asyncio +from typing import Generic, Protocol, TypeVar, runtime_checkable + +InputT = TypeVar("InputT", contravariant=True) +OutputT = TypeVar("OutputT", covariant=True) + + +@runtime_checkable +class IdentityTransport(Protocol): + contract_version: int + + async def exchange(self, packet: bytes, *, maximum_bytes: int) -> bytes: ... + + +@runtime_checkable +class FrameworkAdapter(Protocol, Generic[InputT, OutputT]): + contract_version: int + + async def handle(self, value: InputT) -> OutputT: ... + + +async def exchange_identity( + transport: IdentityTransport, + packet: bytes, + *, + maximum_bytes: int = 128 * 1024, + timeout: float = 10.0, +) -> bytes: + value = bytes(packet) + if not value or maximum_bytes < 1 or maximum_bytes > 16 * 1024 * 1024: + raise ValueError("identity exchange input is outside supported bounds") + if len(value) > maximum_bytes or timeout <= 0 or timeout > 300: + raise ValueError("identity exchange input is outside supported bounds") + result = await asyncio.wait_for( + transport.exchange(value, maximum_bytes=maximum_bytes), timeout + ) + if type(result) is not bytes or not result or len(result) > maximum_bytes: + raise ValueError("identity transport returned an invalid packet") + return result + + +__all__ = ["FrameworkAdapter", "IdentityTransport", "exchange_identity"] diff --git a/bindings/python/python/auths/lifecycle.py b/bindings/python/python/auths/lifecycle.py new file mode 100644 index 00000000..9d654c0e --- /dev/null +++ b/bindings/python/python/auths/lifecycle.py @@ -0,0 +1,371 @@ +"""Typed principal and delegated-authority lifecycle authoring.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass +from typing import Literal, Optional, Protocol, Sequence, Tuple, Union, runtime_checkable + +from . import _native as native +from ._native import SignedObject +from .workflow import ( + ApprovalConfiguration, + ApprovalPolicyReference, + AuthsWorkflowError, + Principal, + ReviewField, + Signer, + _SigningCoordinator, + _call_public_identity, +) + +LifecycleState = Literal["active", "revoked", "superseded"] +_SIGNED_TOKEN = object() +_SNAPSHOT_TOKEN = object() + + +@dataclass(frozen=True) +class ProtocolDigest: + bytes: bytes + + def __post_init__(self) -> None: + value = bytes(self.bytes) + if len(value) != 32: + raise ValueError("protocol digest must contain 32 bytes") + object.__setattr__(self, "bytes", value) + + @classmethod + def parse(cls, value: str) -> ProtocolDigest: + if len(value) != 64 or any(character not in "0123456789abcdef" for character in value): + raise ValueError("protocol digest must be 64 lowercase hexadecimal characters") + return cls(bytes.fromhex(value)) + + +@dataclass(frozen=True) +class CriticalExtension: + id: str + bytes: bytes + + def __post_init__(self) -> None: + object.__setattr__(self, "bytes", bytes(self.bytes)) + + +@dataclass(frozen=True) +class PrincipalStatusRequest: + method: str + principal: Principal + purpose: str + state: LifecycleState + sequence: int + observed_at: int + valid_until: int + issuer: Principal + extensions: Tuple[CriticalExtension, ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "extensions", tuple(self.extensions)) + + +@dataclass(frozen=True) +class GrantStatusRequest: + method: str + grant_id: ProtocolDigest + state: LifecycleState + sequence: int + observed_at: int + valid_until: int + issuer: Principal + extensions: Tuple[CriticalExtension, ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "extensions", tuple(self.extensions)) + + +@dataclass(frozen=True) +class IdentityRotation: + previous: PrincipalStatusRequest + current: PrincipalStatusRequest + + +class SignedPrincipalStatus: + def __init__(self, token: object, value: SignedObject) -> None: + if token is not _SIGNED_TOKEN: + raise TypeError("sealed Auths principal status") + self._value = value + + +class SignedGrantStatus: + def __init__(self, token: object, value: SignedObject) -> None: + if token is not _SIGNED_TOKEN: + raise TypeError("sealed Auths grant status") + self._value = value + + +@dataclass(frozen=True) +class StatusTrustRule: + method: str + issuer: Principal + sequence_floor: int + + +class PrincipalStatusSnapshot: + def __init__( + self, token: object, identifier: ProtocolDigest, native: native.StatusSnapshot + ) -> None: + if token is not _SNAPSHOT_TOKEN: + raise TypeError("sealed Auths principal status snapshot") + self.id = identifier + self._native = native + + +class GrantStatusSnapshot: + def __init__( + self, token: object, identifier: ProtocolDigest, native: native.StatusSnapshot + ) -> None: + if token is not _SNAPSHOT_TOKEN: + raise TypeError("sealed Auths grant status snapshot") + self.id = identifier + self._native = native + + +class LifecycleAuthor: + def __init__( + self, + *, + signer: Signer, + approval: ApprovalConfiguration, + required_approval: ApprovalPolicyReference, + ) -> None: + self._signer = signer + self._approval = approval + self._required_approval = required_approval + self._closed = False + + async def principal_status( + self, request: PrincipalStatusRequest + ) -> SignedPrincipalStatus: + self._assert_open() + if type(request) is not PrincipalStatusRequest: + raise TypeError("request must be a PrincipalStatusRequest") + unsigned = native.principal_status_statement( + request.method, + request.principal, + request.purpose, + request.state, + request.sequence, + request.observed_at, + request.valid_until, + request.issuer, + [(value.id, value.bytes) for value in request.extensions], + ) + return SignedPrincipalStatus( + _SIGNED_TOKEN, + await self._sign(unsigned, request.issuer, request.valid_until, "Principal status"), + ) + + async def grant_status(self, request: GrantStatusRequest) -> SignedGrantStatus: + self._assert_open() + if type(request) is not GrantStatusRequest: + raise TypeError("request must be a GrantStatusRequest") + unsigned = native.grant_status_statement( + request.method, + request.grant_id.bytes, + request.state, + request.sequence, + request.observed_at, + request.valid_until, + request.issuer, + [(value.id, value.bytes) for value in request.extensions], + ) + return SignedGrantStatus( + _SIGNED_TOKEN, + await self._sign(unsigned, request.issuer, request.valid_until, "Grant status"), + ) + + def close(self) -> None: + self._closed = True + + async def _sign( + self, unsigned: native.UnsignedObject, issuer: Principal, expires_at: int, label: str + ) -> SignedObject: + descriptor = await _call_public_identity(self._signer, "lifecycle signer") + if descriptor.principal.value != issuer.value: + raise AuthsWorkflowError( + "invalid-principal", "lifecycle signer does not control the declared issuer" + ) + result = await _SigningCoordinator().execute( + unsigned=unsigned, + principal=descriptor, + signer=self._signer, + approval=self._approval, + required_approval=self._required_approval, + expires_at=expires_at, + display=(ReviewField("Operation", label), ReviewField("Issuer", issuer.value)), + ) + return result.signed_object + + def _assert_open(self) -> None: + if self._closed: + raise AuthsWorkflowError("disposed", "lifecycle author is closed") + + +def principal_status_snapshot( + identifier: ProtocolDigest, + *, + observed_at: int, + valid_until: int, + statements: Sequence[SignedPrincipalStatus], + checkpoints: Sequence[ProtocolDigest] = (), + trust: Sequence[StatusTrustRule] = (), +) -> PrincipalStatusSnapshot: + values = tuple(statements) + if any(type(value) is not SignedPrincipalStatus for value in values): + raise TypeError("principal snapshot contains another status kind") + snapshot = native.status_snapshot( + "principal", + identifier.bytes, + observed_at, + valid_until, + [value._value for value in values], + [value.bytes for value in checkpoints], + [(value.method, value.issuer.value, value.sequence_floor) for value in trust], + ) + return PrincipalStatusSnapshot(_SNAPSHOT_TOKEN, identifier, snapshot) + + +def grant_status_snapshot( + identifier: ProtocolDigest, + *, + observed_at: int, + valid_until: int, + statements: Sequence[SignedGrantStatus], + checkpoints: Sequence[ProtocolDigest] = (), + trust: Sequence[StatusTrustRule] = (), +) -> GrantStatusSnapshot: + values = tuple(statements) + if any(type(value) is not SignedGrantStatus for value in values): + raise TypeError("grant snapshot contains another status kind") + snapshot = native.status_snapshot( + "grant", + identifier.bytes, + observed_at, + valid_until, + [value._value for value in values], + [value.bytes for value in checkpoints], + [(value.method, value.issuer.value, value.sequence_floor) for value in trust], + ) + return GrantStatusSnapshot(_SNAPSHOT_TOKEN, identifier, snapshot) + + +def withdraw_delegation( + *, + method: str, + grant_id: ProtocolDigest, + issuer: Principal, + sequence: int, + valid_for: int, + observed_at: Optional[int] = None, +) -> GrantStatusRequest: + observed = int(time.time()) if observed_at is None else observed_at + return GrantStatusRequest( + method, grant_id, "revoked", sequence, observed, observed + valid_for, issuer + ) + + +def record_compromise( + *, + method: str, + principal: Principal, + purpose: str, + issuer: Principal, + sequence: int, + valid_for: int, + observed_at: Optional[int] = None, +) -> PrincipalStatusRequest: + observed = int(time.time()) if observed_at is None else observed_at + return PrincipalStatusRequest( + method, + principal, + purpose, + "revoked", + sequence, + observed, + observed + valid_for, + issuer, + ) + + +def rotate_identity( + *, + method: str, + previous: Principal, + current: Principal, + purpose: str, + issuer: Principal, + previous_sequence: int, + current_sequence: int, + valid_for: int, + observed_at: Optional[int] = None, +) -> IdentityRotation: + observed = int(time.time()) if observed_at is None else observed_at + valid_until = observed + valid_for + return IdentityRotation( + PrincipalStatusRequest( + method, + previous, + purpose, + "superseded", + previous_sequence, + observed, + valid_until, + issuer, + ), + PrincipalStatusRequest( + method, + current, + purpose, + "active", + current_sequence, + observed, + valid_until, + issuer, + ), + ) + + +StatusSnapshot = Union[PrincipalStatusSnapshot, GrantStatusSnapshot] + + +@runtime_checkable +class StatusProvider(Protocol): + contract_version: int + + async def principal( + self, identifier: ProtocolDigest, *, observed_at: int + ) -> PrincipalStatusSnapshot: ... + + async def grant( + self, identifier: ProtocolDigest, *, observed_at: int + ) -> GrantStatusSnapshot: ... + +__all__ = [ + "CriticalExtension", + "GrantStatusRequest", + "GrantStatusSnapshot", + "IdentityRotation", + "LifecycleAuthor", + "LifecycleState", + "PrincipalStatusRequest", + "PrincipalStatusSnapshot", + "ProtocolDigest", + "SignedGrantStatus", + "SignedPrincipalStatus", + "StatusSnapshot", + "StatusProvider", + "StatusTrustRule", + "grant_status_snapshot", + "principal_status_snapshot", + "record_compromise", + "rotate_identity", + "withdraw_delegation", +] diff --git a/bindings/python/python/auths/observability.py b/bindings/python/python/auths/observability.py new file mode 100644 index 00000000..f8ccee98 --- /dev/null +++ b/bindings/python/python/auths/observability.py @@ -0,0 +1,117 @@ +"""Bounded Auths telemetry and redacted support evidence.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from typing import Mapping, Protocol, Sequence, Tuple, Union, runtime_checkable + +AttributeValue = Union[str, int, bool] +MAX_EVENTS = 256 +MAX_ATTRIBUTES = 32 +MAX_TEXT_BYTES = 256 +SENSITIVE_ATTRIBUTE_PARTS = ( + "proof", + "signature", + "private", + "credential", + "secret", + "token", + "payload", + "cbor", + "public_key", + "idempotency_key", +) + + +@dataclass(frozen=True) +class AuthsEvent: + name: str + operation: str + stage: str + outcome: str + observed_at: int + attributes: Tuple[Tuple[str, AttributeValue], ...] = () + + def __post_init__(self) -> None: + fields = (self.name, self.operation, self.stage, self.outcome) + if any(not value or len(value.encode()) > MAX_TEXT_BYTES for value in fields): + raise ValueError("telemetry event contains an invalid field") + attributes = tuple(self.attributes) + _validate_attributes(attributes) + object.__setattr__(self, "attributes", attributes) + + +@runtime_checkable +class Telemetry(Protocol): + def emit(self, event: AuthsEvent) -> None: ... + + +class DecisionTimeline: + def __init__(self) -> None: + self._events: list[AuthsEvent] = [] + + def append(self, event: AuthsEvent) -> None: + if type(event) is not AuthsEvent: + raise TypeError("timeline accepts AuthsEvent values") + if len(self._events) >= MAX_EVENTS: + raise ValueError("decision timeline is full") + self._events.append(event) + + def snapshot(self) -> Tuple[AuthsEvent, ...]: + return tuple(self._events) + + +def support_bundle( + events: Sequence[AuthsEvent], + *, + runtime: Mapping[str, AttributeValue], +) -> bytes: + values = tuple(events) + if len(values) > MAX_EVENTS or any(type(value) is not AuthsEvent for value in values): + raise ValueError("support bundle event collection is invalid") + runtime_values = tuple(runtime.items()) + _validate_attributes(runtime_values) + document = { + "schema": "auths.python-support-bundle/1", + "runtime": {key: runtime[key] for key in sorted(runtime)}, + "events": [ + { + "name": event.name, + "operation": event.operation, + "stage": event.stage, + "outcome": event.outcome, + "observedAt": event.observed_at, + "attributes": {key: value for key, value in sorted(event.attributes)}, + } + for event in values + ], + } + return json.dumps(document, sort_keys=True, separators=(",", ":")).encode() + + +def _validate_attributes( + attributes: Sequence[Tuple[str, AttributeValue]], +) -> None: + if len(attributes) > MAX_ATTRIBUTES: + raise ValueError("telemetry contains too many attributes") + names: set[str] = set() + for key, value in attributes: + normalized = key.lower().replace("-", "_") + if ( + not key + or len(key.encode()) > MAX_TEXT_BYTES + or key in names + or any(part in normalized for part in SENSITIVE_ATTRIBUTE_PARTS) + ): + raise ValueError("telemetry attribute name is invalid") + if type(value) is str and len(value.encode()) > MAX_TEXT_BYTES: + raise ValueError("telemetry attribute value is too large") + if type(value) is int and not -(1 << 63) <= value < (1 << 63): + raise ValueError("telemetry integer is outside supported bounds") + if type(value) not in (str, int, bool): + raise TypeError("telemetry attributes must be low-cardinality scalar values") + names.add(key) + + +__all__ = ["AuthsEvent", "DecisionTimeline", "Telemetry", "support_bundle"] diff --git a/bindings/python/python/auths/profile_kit.py b/bindings/python/python/auths/profile_kit.py new file mode 100644 index 00000000..c308e7c2 --- /dev/null +++ b/bindings/python/python/auths/profile_kit.py @@ -0,0 +1,747 @@ +"""Application-owned profiles over the native Auths workflow waist.""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass, field +from typing import ( + Any, + Awaitable, + Callable, + Generic, + Literal, + Optional, + Sequence, + Tuple, + TypeVar, + Union, + cast, +) + +from . import _native as native +from ._plan import PlanApprovalSession +from .workflow import ( + ApprovalConfiguration, + ApprovalProvider, + AttachedAgent, + AuthsWorkflowError, + ControlEvidence, + Permission, + Profile, + ReviewField, + _SigningCoordinator, + _transaction_expiry, +) + +InputT = TypeVar("InputT") +CommandT = TypeVar("CommandT") +ResultT = TypeVar("ResultT") +ApplicationOutcome = Literal["succeeded", "failed", "cancelled", "outcome-unknown"] +ApplicationExecutionState = Literal["committed", "outcome-unknown"] +VerificationStage = Literal[ + "decode", "resolve", "principal-control", "authority", "complete" +] +_ACTION_TOKEN = object() +_PLAN_TOKEN = object() + + +@dataclass(frozen=True) +class ProfilePermission: + capability: str + resource: str + + +@dataclass(frozen=True) +class ProfileBudget: + algebra: str + value: int + + +@dataclass(frozen=True) +class CanonicalProfileAction: + media_type: str + body: bytes + permission: ProfilePermission + resource_namespace: str + audience: str + display: Tuple[ReviewField, ...] + budget: Optional[ProfileBudget] = None + + def __post_init__(self) -> None: + object.__setattr__(self, "body", bytes(self.body)) + object.__setattr__(self, "display", tuple(self.display)) + + +@dataclass(frozen=True) +class ProfileDefinition(Generic[InputT, CommandT]): + id: str + version: int + canonicalize: Callable[[InputT], CanonicalProfileAction] + decode_verified: Callable[[CanonicalProfileAction], CommandT] + + +@dataclass(frozen=True) +class ApplicationAuthority: + permissions: Tuple[Permission, ...] + resource_namespaces: Tuple[str, ...] + audiences: Tuple[str, ...] + budget: Optional[ProfileBudget] + + +@dataclass(frozen=True) +class ApplicationReview: + title: str + fields: Tuple[ReviewField, ...] + action_commitment: bytes + + +class ApplicationAction(Generic[InputT]): + def __init__( + self, + token: object, + profile: ApplicationProfile[InputT, Any], + canonical: CanonicalProfileAction, + native_action: native.ApplicationAction, + ) -> None: + if token is not _ACTION_TOKEN: + raise TypeError("sealed Auths application action") + self._profile = profile + self._canonical = canonical + self._native = native_action + + @property + def profile(self) -> ApplicationProfile[InputT, Any]: + return self._profile + + +class ApplicationPlan(Generic[InputT]): + def __init__( + self, + token: object, + profile: ApplicationProfile[InputT, Any], + actions: Tuple[ApplicationAction[InputT], ...], + commitment: bytes, + member_commitments: Tuple[bytes, ...], + authority: ApplicationAuthority, + ) -> None: + if token is not _PLAN_TOKEN: + raise TypeError("sealed Auths application plan") + self._profile = profile + self._actions = actions + self._commitment = commitment + self._member_commitments = member_commitments + self._authority = authority + + @property + def length(self) -> int: + return len(self._actions) + + @property + def commitment(self) -> bytes: + return self._commitment + + @property + def authority(self) -> ApplicationAuthority: + return self._authority + + +@dataclass(frozen=True) +class ApplicationRequest: + challenge: bytes = field(default_factory=native.generate_challenge_v1) + evaluation_time: int = field(default_factory=lambda: int(time.time())) + + def __post_init__(self) -> None: + challenge = bytes(self.challenge) + if len(challenge) != 32: + raise ValueError("authorization challenge must contain 32 bytes") + if type(self.evaluation_time) is not int or not 0 <= self.evaluation_time <= (1 << 64) - 1: + raise ValueError("invalid authorization evaluation time") + object.__setattr__(self, "challenge", challenge) + + +@dataclass(frozen=True) +class ApplicationMetrics: + proof_bytes: int + action_bytes: int + context_bytes: int + object_count: int + plan_leaves: int + plan_depth: int + work_units: int + + +@dataclass(frozen=True) +class ApplicationExplanation: + code: str + message: str + retryable: bool + + +@dataclass(frozen=True) +class ApplicationApproval: + policy_id: str + evaluator_version: str + required_configuration: bytes + executed_configuration: bytes + executed_mode: str + executed_max_uses: int + transaction_digest: bytes + + +@dataclass(frozen=True) +class ApplicationAuthorized(Generic[CommandT]): + kind: Literal["authorized"] + code: str + stage: VerificationStage + explanation: ApplicationExplanation + metrics: ApplicationMetrics + approval: ApplicationApproval + required_configuration: Optional[bytes] + local_configuration: bytes + result_cbor: bytes + command: native.ApplicationCommand + + +@dataclass(frozen=True) +class ApplicationDenied: + kind: Literal["denied"] + code: str + stage: VerificationStage + explanation: ApplicationExplanation + metrics: ApplicationMetrics + approval: ApplicationApproval + required_configuration: Optional[bytes] + local_configuration: bytes + result_cbor: bytes + + +@dataclass(frozen=True) +class ApplicationIndeterminate: + kind: Literal["indeterminate"] + code: str + stage: VerificationStage + explanation: ApplicationExplanation + metrics: ApplicationMetrics + approval: ApplicationApproval + required_configuration: Optional[bytes] + local_configuration: bytes + result_cbor: bytes + + +ApplicationResult = Union[ApplicationAuthorized[CommandT], ApplicationDenied, ApplicationIndeterminate] + + +@dataclass(frozen=True) +class ApplicationPlanAuthorized(Generic[CommandT]): + kind: Literal["authorized"] + command: native.ApplicationPlanCommand + results: Tuple[ApplicationAuthorized[CommandT], ...] + + +@dataclass(frozen=True) +class ApplicationPlanDenied: + kind: Literal["denied"] + failed_index: int + result: ApplicationDenied + + +@dataclass(frozen=True) +class ApplicationPlanIndeterminate: + kind: Literal["indeterminate"] + failed_index: int + result: ApplicationIndeterminate + + +ApplicationPlanResult = Union[ + ApplicationPlanAuthorized[CommandT], ApplicationPlanDenied, ApplicationPlanIndeterminate +] + + +@dataclass(frozen=True) +class ApplicationReceipt: + idempotency_key: str + command_commitment: bytes + authority_commitment: bytes + context_commitment: bytes + plan_commitment: Optional[bytes] + state_claim: ApplicationExecutionState + outcome: ApplicationOutcome + observed_at: int + + +class ApplicationGatewayError(AuthsWorkflowError): + def __init__( + self, + receipt: ApplicationReceipt, + completed_receipts: Tuple[ApplicationReceipt, ...] = (), + ) -> None: + super().__init__( + "gateway-failed", + "application gateway execution outcome is unknown", + operation="execute", + stage="provider", + retry="unknown", + effect_state="outcome-unknown", + remediation="reconcile the idempotency key before another execution attempt", + ) + self.receipt = receipt + self.completed_receipts = completed_receipts + + +class ApplicationGatewayCancelled(AuthsWorkflowError): + def __init__( + self, + receipt: ApplicationReceipt, + completed_receipts: Tuple[ApplicationReceipt, ...] = (), + ) -> None: + super().__init__( + "gateway-cancelled", + "application gateway task was cancelled after provider entry", + operation="execute", + stage="provider", + retry="unknown", + effect_state="outcome-unknown", + remediation="reconcile the idempotency key before another execution attempt", + ) + self.receipt = receipt + self.completed_receipts = completed_receipts + + +class ApplicationGateway(Generic[CommandT, ResultT]): + def __init__( + self, + profile: ApplicationProfile[Any, CommandT], + executor: Callable[[CommandT], Awaitable[ResultT]], + ) -> None: + self._profile = profile + self._executor = executor + + async def execute( + self, command: native.ApplicationCommand, *, idempotency_key: str + ) -> Tuple[ResultT, ApplicationReceipt]: + if type(command) is not native.ApplicationCommand or not idempotency_key: + raise TypeError( + "gateway requires a native application command and idempotency key" + ) + binding = ( + bytes(command.action_commitment), + bytes(command.authority_commitment), + bytes(command.context_commitment), + ) + call = native.consume_application_command( + command, self._profile.id, self._profile.version + ) + decoded = self._profile._decode(_canonical_from_call(call)) + try: + result = await self._executor(decoded) + except asyncio.CancelledError: + raise ApplicationGatewayCancelled( + _receipt(idempotency_key, binding, None, "cancelled") + ) from None + except Exception: + raise ApplicationGatewayError( + _receipt( + idempotency_key, + binding, + None, + "outcome-unknown", + ) + ) from None + return result, _receipt(idempotency_key, binding, None, "succeeded") + + async def execute_plan( + self, command: native.ApplicationPlanCommand, *, idempotency_key: str + ) -> Tuple[Tuple[ResultT, ...], Tuple[ApplicationReceipt, ...]]: + if type(command) is not native.ApplicationPlanCommand or not idempotency_key: + raise TypeError( + "gateway requires a native application plan command and idempotency key" + ) + plan_commitment = bytes(command.plan_commitment) + bindings = tuple( + (bytes(action), bytes(authority), bytes(context)) + for action, authority, context in command.receipt_bindings + ) + if len(bindings) != command.count: + raise RuntimeError("native application plan command omitted receipt bindings") + calls = native.consume_application_plan_command( + command, self._profile.id, self._profile.version + ) + results: list[ResultT] = [] + receipts: list[ApplicationReceipt] = [] + for index, (call, binding) in enumerate(zip(calls, bindings)): + decoded = self._profile._decode(_canonical_from_call(call)) + member_key = f"{idempotency_key}:{index}" + try: + results.append(await self._executor(decoded)) + except asyncio.CancelledError: + raise ApplicationGatewayCancelled( + _receipt( + member_key, + binding, + plan_commitment, + "cancelled", + ), + tuple(receipts), + ) from None + except Exception: + raise ApplicationGatewayError( + _receipt( + member_key, + binding, + plan_commitment, + "outcome-unknown", + ), + tuple(receipts), + ) from None + receipts.append( + _receipt( + member_key, + binding, + plan_commitment, + "succeeded", + ) + ) + return tuple(results), tuple(receipts) + + +class ApplicationProfile(Profile, Generic[InputT, CommandT]): + def __init__(self, definition: ProfileDefinition[InputT, CommandT]) -> None: + if not callable(definition.canonicalize) or not callable(definition.decode_verified): + raise TypeError("profile requires canonicalize and decode_verified callables") + super().__init__(definition.id, definition.version) + self._canonicalize = definition.canonicalize + self._decode = definition.decode_verified + + def action(self, value: InputT) -> ApplicationAction[InputT]: + try: + canonical = self._canonicalize(value) + except Exception: + raise AuthsWorkflowError("invalid-profile", "profile rejected the action") from None + if type(canonical) is not CanonicalProfileAction: + raise TypeError("profile canonicalizer must return CanonicalProfileAction") + native_action = native.application_action( + self.id, + self.version, + canonical.media_type, + canonical.body, + canonical.permission.capability, + canonical.permission.resource, + None if canonical.budget is None else (canonical.budget.algebra, canonical.budget.value), + canonical.resource_namespace, + canonical.audience, + ) + return ApplicationAction(_ACTION_TOKEN, self, canonical, native_action) + + def authority_for(self, action: ApplicationAction[InputT]) -> ApplicationAuthority: + self._assert_action(action) + canonical = action._canonical + return ApplicationAuthority( + (Permission(canonical.permission.capability, canonical.permission.resource),), + (canonical.resource_namespace,), + (canonical.audience,), + canonical.budget, + ) + + def inspect_action(self, action: ApplicationAction[InputT]) -> CanonicalProfileAction: + self._assert_action(action) + return action._canonical + + def review(self, action: ApplicationAction[InputT]) -> ApplicationReview: + self._assert_action(action) + return ApplicationReview( + f"{self.id}/{self.version}", + action._canonical.display, + bytes(native.application_action_commitment_v1(action._native)), + ) + + def plan(self, actions: Sequence[ApplicationAction[InputT]]) -> ApplicationPlan[InputT]: + values = tuple(actions) + if not values or any(value._profile is not self for value in values): + raise AuthsWorkflowError("invalid-profile", "application plan contains an incompatible action") + projection = native.commit_application_plan([value._native for value in values]) + first = values[0]._canonical + aggregate = sum(value._canonical.budget.value for value in values if value._canonical.budget is not None) + budget = None if first.budget is None else ProfileBudget(first.budget.algebra, aggregate) + authority = ApplicationAuthority( + tuple( + dict.fromkeys( + Permission(value._canonical.permission.capability, value._canonical.permission.resource) + for value in values + ) + ), + (first.resource_namespace,), + (first.audience,), + budget, + ) + return ApplicationPlan( + _PLAN_TOKEN, + self, + values, + bytes(projection.commitment), + tuple(bytes(value) for value in projection.members), + authority, + ) + + def gateway( + self, executor: Callable[[CommandT], Awaitable[ResultT]] + ) -> ApplicationGateway[CommandT, ResultT]: + if not callable(executor): + raise TypeError("application gateway executor must be callable") + return ApplicationGateway(self, executor) + + def _assert_action(self, action: ApplicationAction[InputT]) -> None: + if type(action) is not ApplicationAction or action._profile is not self: + raise AuthsWorkflowError("invalid-profile", "action belongs to another profile") + + +def define_profile( + definition: ProfileDefinition[InputT, CommandT], +) -> ApplicationProfile[InputT, CommandT]: + if type(definition) is not ProfileDefinition: + raise TypeError("profile definition is required") + return ApplicationProfile(definition) + + +def _receipt( + idempotency_key: str, + binding: Tuple[bytes, bytes, bytes], + plan_commitment: Optional[bytes], + outcome: ApplicationOutcome, +) -> ApplicationReceipt: + return ApplicationReceipt( + idempotency_key, + binding[0], + binding[1], + binding[2], + plan_commitment, + cast(ApplicationExecutionState, native.runtime_execution_state_v1(outcome)), + outcome, + int(time.time()), + ) + + +async def _authorize_application( + agent: AttachedAgent, + action: ApplicationAction[object], + request: Optional[ApplicationRequest], + approval_override: Optional[ApprovalConfiguration] = None, +) -> ApplicationResult[object]: + agent._assert_active() + if type(action) is not ApplicationAction or action._profile is not agent._profile: + raise AuthsWorkflowError("profile-mismatch", "application action belongs to another profile") + request = ApplicationRequest() if request is None else request + if type(request) is not ApplicationRequest: + raise TypeError("request must be an ApplicationRequest") + prepared = native.prepare_application_action( + action._native, + agent.identity.principal.principal, + agent._grant_chain[-1].signed_grant, + request.challenge, + request.evaluation_time, + ) + approval_configuration = agent._approval if approval_override is None else approval_override + signed = await _SigningCoordinator().execute( + unsigned=prepared.unsigned, + principal=agent.identity.principal, + signer=agent._signer, + approval=approval_configuration, + required_approval=agent._client._configured_authority.required_approval, + expires_at=_transaction_expiry(approval_configuration.policy.expires_in_seconds), + display=action._canonical.display, + ) + native_result, command = native.authorize_application( + prepared, + signed.signed_object, + [value.signed_grant for value in agent._grant_chain], + [[_native_evidence(evidence) for evidence in value.evidence] for value in agent._grant_chain], + [_native_evidence(value) for value in signed.evidence], + agent._client._configured_authority.context, + ) + metrics = ApplicationMetrics(*native_result.metrics) + approval = ApplicationApproval( + approval_configuration.policy.reference.policy_id, + approval_configuration.policy.reference.evaluator_version, + bytes(agent._client._configured_authority.required_approval.configuration_digest), + bytes(approval_configuration.policy.reference.configuration_digest), + approval_configuration.policy.mode, + approval_configuration.policy.max_uses, + signed.transaction_digest, + ) + explanation = _explanation(native_result.kind, native_result.code) + required = native_result.required_configuration + local = bytes(native_result.local_configuration) + encoded = bytes(native_result.result_cbor) + if native_result.kind == "authorized": + if command is None: + raise AuthsWorkflowError("native-authorization-failed", "application authorization omitted its command") + return ApplicationAuthorized( + "authorized", + native_result.code, + native_result.stage, + explanation, + metrics, + approval, + required, + local, + encoded, + command, + ) + if command is not None: + raise AuthsWorkflowError("native-authorization-failed", "failed application decision returned a command") + if native_result.kind == "denied": + return ApplicationDenied( + "denied", + native_result.code, + native_result.stage, + explanation, + metrics, + approval, + required, + local, + encoded, + ) + return ApplicationIndeterminate( + "indeterminate", + native_result.code, + native_result.stage, + explanation, + metrics, + approval, + required, + local, + encoded, + ) + + +async def _authorize_application_plan( + agent: AttachedAgent, + plan: ApplicationPlan[object], + approval_provider: Optional[ApprovalProvider], + requests: Optional[Sequence[ApplicationRequest]], +) -> ApplicationPlanResult[object]: + if type(plan) is not ApplicationPlan or plan._profile is not agent._profile: + raise AuthsWorkflowError("profile-mismatch", "application plan belongs to another profile") + agent._assert_active() + _validate_application_plan(plan) + approval = agent._approval + if approval.policy.mode != "plan-once" or approval.policy.max_uses != plan.length: + raise AuthsWorkflowError("approval-policy-mismatch", "plan-once approval must match the application plan") + provider = approval.provider if approval_provider is None else approval_provider + request_values = tuple(ApplicationRequest() for _ in plan._actions) if requests is None else tuple(requests) + if len(request_values) != plan.length: + raise ValueError("authorization requests must match the application plan") + expires_at = int(time.time()) + approval.policy.expires_in_seconds + plan_approval = native.commit_plan_approval( + plan._commitment, + approval.policy.reference.configuration_digest, + approval.policy.max_uses, + expires_at, + ) + session = PlanApprovalSession( + plan_approval=bytes(plan_approval), + member_commitments=plan._member_commitments, + approval=approval, + provider=provider, + expires_at=expires_at, + display=(ReviewField("Profile", f"{plan._profile.id}/{plan._profile.version}"), ReviewField("Actions", str(plan.length))), + ) + results: list[ApplicationResult[object]] = [] + try: + for index, (action, request) in enumerate(zip(plan._actions, request_values)): + _validate_application_plan(plan) + member_approval = ApprovalConfiguration( + approval.policy, + session.provider_for(index, plan._member_commitments[index]), + ) + result = await _authorize_application(agent, action, request, member_approval) + results.append(result) + if isinstance(result, ApplicationDenied): + return ApplicationPlanDenied("denied", index, result) + if isinstance(result, ApplicationIndeterminate): + return ApplicationPlanIndeterminate("indeterminate", index, result) + authorized = cast( + Tuple[ApplicationAuthorized[object], ...], + tuple( + value for value in results if isinstance(value, ApplicationAuthorized) + ), + ) + command = native.seal_application_plan_command( + [value.command for value in authorized], + plan._profile.id, + plan._profile.version, + plan._commitment, + ) + return ApplicationPlanAuthorized("authorized", command, authorized) + finally: + session.dispose() + + +def _validate_application_plan(plan: ApplicationPlan[object]) -> None: + projection = native.commit_application_plan( + [action._native for action in plan._actions] + ) + if not native.commitments_equal_v1(bytes(projection.commitment), plan._commitment): + raise AuthsWorkflowError("invalid-profile", "application plan membership changed") + if len(projection.members) != len(plan._member_commitments): + raise AuthsWorkflowError("invalid-profile", "application plan membership changed") + for actual, expected in zip(projection.members, plan._member_commitments): + if not native.commitments_equal_v1(bytes(actual), expected): + raise AuthsWorkflowError( + "invalid-profile", "application plan membership changed" + ) + + +def _canonical_from_call(call: native.ApplicationGatewayCall) -> CanonicalProfileAction: + permission = ProfilePermission(*call.permission) + budget = None if call.budget is None else ProfileBudget(*call.budget) + return CanonicalProfileAction( + call.media_type, + bytes(call.body), + permission, + call.resource_namespace, + call.audience, + (), + budget, + ) + + +def _native_evidence(value: ControlEvidence) -> Tuple[str, str, bytes]: + return value.evidence_type, value.media_type, value.bytes + + +def _explanation(kind: str, code: str) -> ApplicationExplanation: + if kind == "authorized": + message = "the proof establishes exact authority for this application action" + elif kind == "denied": + message = "the supplied proof does not authorize this application action" + else: + message = "a required trustworthy fact or implementation is unavailable" + return ApplicationExplanation(code, message, kind == "indeterminate") + + +__all__ = [ + "ApplicationAction", + "ApplicationAuthority", + "ApplicationGateway", + "ApplicationGatewayCancelled", + "ApplicationGatewayError", + "ApplicationPlan", + "ApplicationPlanAuthorized", + "ApplicationPlanDenied", + "ApplicationPlanIndeterminate", + "ApplicationPlanResult", + "ApplicationProfile", + "ApplicationRequest", + "ApplicationReceipt", + "ApplicationReview", + "ApplicationResult", + "ApplicationAuthorized", + "ApplicationDenied", + "ApplicationIndeterminate", + "CanonicalProfileAction", + "ProfileBudget", + "ProfileDefinition", + "ProfilePermission", + "define_profile", +] diff --git a/bindings/python/python/auths/profiles/__init__.py b/bindings/python/python/auths/profiles/__init__.py new file mode 100644 index 00000000..b3fe299a --- /dev/null +++ b/bindings/python/python/auths/profiles/__init__.py @@ -0,0 +1,5 @@ +"""Maintained Auths action profiles.""" + +from . import http, mcp + +__all__ = ["http", "mcp"] diff --git a/bindings/python/python/auths/profiles/http.py b/bindings/python/python/auths/profiles/http.py new file mode 100644 index 00000000..289b3d8a --- /dev/null +++ b/bindings/python/python/auths/profiles/http.py @@ -0,0 +1,699 @@ +"""Closed HTTP authorization and execution profile.""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass, field +from typing import ( + Awaitable, + Callable, + Generic, + Literal, + Mapping, + Optional, + Sequence, + Tuple, + TypeVar, + Union, + cast, +) + +from .. import _native as native +from .._plan import PlanApprovalSession +from ..workflow import ( + ApprovalConfiguration, + ApprovalProvider, + AttachedAgent, + AuthsWorkflowError, + ControlEvidence, + Permission, + Profile, + ReviewField, + _SigningCoordinator, + _transaction_expiry, +) + +VerificationStage = Literal[ + "decode", "resolve", "principal-control", "authority", "complete" +] +HttpOutcome = Literal["succeeded", "failed", "cancelled", "outcome-unknown"] +HttpExecutionState = Literal["committed", "outcome-unknown"] +_PLAN_TOKEN = object() + + +class HttpProfileError(AuthsWorkflowError): + pass + + +@dataclass(frozen=True) +class HttpAuthorizationRequest: + challenge: bytes = field(default_factory=native.generate_challenge_v1) + evaluation_time: int = field(default_factory=lambda: int(time.time())) + + def __post_init__(self) -> None: + challenge = bytes(self.challenge) + if len(challenge) != 32: + raise ValueError("authorization challenge must contain 32 bytes") + if type(self.evaluation_time) is not int or not 0 <= self.evaluation_time <= (1 << 64) - 1: + raise ValueError("invalid authorization evaluation time") + object.__setattr__(self, "challenge", challenge) + + +@dataclass(frozen=True) +class HttpReview: + title: str + fields: Tuple[ReviewField, ...] + action_commitment: bytes + + +class HttpProfile(Profile): + def __init__(self, *, scheme: str, authority: str) -> None: + super().__init__("auths.http", 1) + probe = native.http_call("GET", scheme, authority, "/", [], [], None, None) + self._origin = f"{probe.scheme}://{probe.authority}" + self.scheme = probe.scheme + self.authority = probe.authority + + @property + def origin(self) -> str: + return self._origin + + def request( + self, + method: str, + path: str, + *, + query: Optional[Mapping[str, Sequence[str]]] = None, + headers: Optional[Mapping[str, str]] = None, + content_type: Optional[str] = None, + body_digest: Optional[str] = None, + ) -> HttpAction: + try: + call = native.http_call( + method, + self.scheme, + self.authority, + path, + [] + if query is None + else [(key, list(values)) for key, values in query.items()], + [] if headers is None else list(headers.items()), + content_type, + body_digest, + ) + except (TypeError, ValueError): + raise ValueError("invalid HTTP action") from None + return HttpAction(self, call) + + def review(self, action: HttpAction) -> HttpReview: + if type(action) is not HttpAction or action.profile is not self: + raise HttpProfileError( + "profile-mismatch", "HTTP action belongs to another profile" + ) + title, fields, commitment = native.review_http_call(action._call) + return HttpReview( + title, + tuple(ReviewField(label, value) for label, value in fields), + bytes(commitment), + ) + + def plan(self, actions: Sequence[HttpAction]) -> HttpPlan: + values = tuple(actions) + if not values or len(values) > 256: + raise HttpProfileError("invalid-profile", "HTTP plan action count is outside bounds") + if any(type(action) is not HttpAction or action.profile is not self for action in values): + raise HttpProfileError("invalid-profile", "HTTP plan contains an action from another profile") + projection = native.commit_http_plan([action._call for action in values]) + authority = HttpPlanAuthority( + tuple(Permission(capability, resource) for capability, resource in projection.permissions), + tuple(projection.resource_namespaces), + tuple(projection.audiences), + ) + return HttpPlan( + _PLAN_TOKEN, + self, + values, + bytes(projection.commitment), + tuple(bytes(value) for value in projection.members), + authority, + ) + + def gateway( + self, executor: Callable[[HttpGatewayRequest], Awaitable[GatewayResult]] + ) -> HttpGateway[GatewayResult]: + if not callable(executor): + raise TypeError("HTTP gateway executor must be callable") + return HttpGateway(self.origin, executor) + + +class HttpAction: + def __init__(self, profile: HttpProfile, call: native.HttpCall) -> None: + self._profile = profile + self._call = call + + @property + def profile(self) -> HttpProfile: + return self._profile + + @property + def method(self) -> str: + return self._call.method + + @property + def path(self) -> str: + return self._call.path + + +@dataclass(frozen=True) +class HttpPlanAuthority: + permissions: Tuple[Permission, ...] + resource_namespaces: Tuple[str, ...] + audiences: Tuple[str, ...] + + +class HttpPlan: + def __init__( + self, + token: object, + profile: HttpProfile, + actions: Tuple[HttpAction, ...], + commitment: bytes, + member_commitments: Tuple[bytes, ...], + authority: HttpPlanAuthority, + ) -> None: + if token is not _PLAN_TOKEN: + raise TypeError("sealed Auths HTTP plan") + self._profile = profile + self._actions = actions + self._commitment = commitment + self._member_commitments = member_commitments + self._authority = authority + + @property + def length(self) -> int: + return len(self._actions) + + @property + def commitment(self) -> bytes: + return self._commitment + + @property + def authority(self) -> HttpPlanAuthority: + return self._authority + + +@dataclass(frozen=True) +class HttpAuthorizationMetrics: + proof_bytes: int + action_bytes: int + context_bytes: int + object_count: int + plan_leaves: int + plan_depth: int + work_units: int + + +@dataclass(frozen=True) +class HttpExplanation: + code: str + message: str + retryable: bool + + +@dataclass(frozen=True) +class HttpApproval: + policy_id: str + evaluator_version: str + required_configuration: bytes + executed_configuration: bytes + executed_mode: str + executed_max_uses: int + transaction_digest: bytes + + +@dataclass(frozen=True) +class HttpAuthorized: + kind: Literal["authorized"] + code: str + stage: VerificationStage + explanation: HttpExplanation + metrics: HttpAuthorizationMetrics + approval: HttpApproval + required_configuration: Optional[bytes] + local_configuration: bytes + result_cbor: bytes + action_commitment: bytes + command: native.HttpCommand + + +@dataclass(frozen=True) +class HttpDenied: + kind: Literal["denied"] + code: str + stage: VerificationStage + explanation: HttpExplanation + metrics: HttpAuthorizationMetrics + approval: HttpApproval + required_configuration: Optional[bytes] + local_configuration: bytes + result_cbor: bytes + + +@dataclass(frozen=True) +class HttpIndeterminate: + kind: Literal["indeterminate"] + code: str + stage: VerificationStage + explanation: HttpExplanation + metrics: HttpAuthorizationMetrics + approval: HttpApproval + required_configuration: Optional[bytes] + local_configuration: bytes + result_cbor: bytes + + +HttpAuthorizationResult = Union[HttpAuthorized, HttpDenied, HttpIndeterminate] + + +@dataclass(frozen=True) +class HttpPlanAuthorized: + kind: Literal["authorized"] + command: native.HttpPlanCommand + results: Tuple[HttpAuthorized, ...] + + +@dataclass(frozen=True) +class HttpPlanDenied: + kind: Literal["denied"] + failed_index: int + result: HttpDenied + results: Tuple[HttpAuthorizationResult, ...] + + +@dataclass(frozen=True) +class HttpPlanIndeterminate: + kind: Literal["indeterminate"] + failed_index: int + result: HttpIndeterminate + results: Tuple[HttpAuthorizationResult, ...] + + +HttpPlanAuthorizationResult = Union[HttpPlanAuthorized, HttpPlanDenied, HttpPlanIndeterminate] + + +@dataclass(frozen=True) +class HttpGatewayRequest: + method: str + scheme: str + authority: str + path: str + query: Tuple[Tuple[str, Tuple[str, ...]], ...] + headers: Tuple[Tuple[str, str], ...] + content_type: Optional[str] + body_digest: Optional[str] + + +@dataclass(frozen=True) +class HttpReceipt: + idempotency_key: str + command_commitment: bytes + authority_commitment: bytes + context_commitment: bytes + plan_commitment: Optional[bytes] + state_claim: HttpExecutionState + outcome: HttpOutcome + observed_at: int + + +class HttpGatewayError(HttpProfileError): + def __init__( + self, + receipt: HttpReceipt, + completed_receipts: Tuple[HttpReceipt, ...] = (), + ) -> None: + super().__init__( + "gateway-failed", + "HTTP gateway execution outcome is unknown", + operation="execute", + stage="provider", + retry="unknown", + effect_state="outcome-unknown", + remediation="reconcile the idempotency key before another execution attempt", + ) + self.receipt = receipt + self.completed_receipts = completed_receipts + + +class HttpGatewayCancelled(HttpProfileError): + def __init__( + self, + receipt: HttpReceipt, + completed_receipts: Tuple[HttpReceipt, ...] = (), + ) -> None: + super().__init__( + "gateway-cancelled", + "HTTP gateway task was cancelled after provider entry", + operation="execute", + stage="provider", + retry="unknown", + effect_state="outcome-unknown", + remediation="reconcile the idempotency key before another execution attempt", + ) + self.receipt = receipt + self.completed_receipts = completed_receipts + + +GatewayResult = TypeVar("GatewayResult") + + +class HttpGateway(Generic[GatewayResult]): + def __init__( + self, + origin: str, + executor: Callable[[HttpGatewayRequest], Awaitable[GatewayResult]], + ) -> None: + self._origin = origin + self._executor = executor + + async def execute( + self, command: native.HttpCommand, *, idempotency_key: str + ) -> Tuple[GatewayResult, HttpReceipt]: + if type(command) is not native.HttpCommand or not idempotency_key: + raise TypeError("gateway requires a native HTTP command and idempotency key") + binding = ( + bytes(command.action_commitment), + bytes(command.authority_commitment), + bytes(command.context_commitment), + ) + request = _gateway_request(native.consume_http_command(command, self._origin)) + try: + result = await self._executor(request) + except asyncio.CancelledError: + raise HttpGatewayCancelled( + _receipt(idempotency_key, binding, None, "cancelled") + ) from None + except Exception: + raise HttpGatewayError( + _receipt( + idempotency_key, + binding, + None, + "outcome-unknown", + ) + ) from None + return result, _receipt(idempotency_key, binding, None, "succeeded") + + async def execute_plan( + self, command: native.HttpPlanCommand, *, idempotency_key: str + ) -> Tuple[Tuple[GatewayResult, ...], Tuple[HttpReceipt, ...]]: + if type(command) is not native.HttpPlanCommand or not idempotency_key: + raise TypeError("gateway requires a native HTTP plan command and idempotency key") + plan_commitment = bytes(command.plan_commitment) + bindings = tuple( + (bytes(action), bytes(authority), bytes(context)) + for action, authority, context in command.receipt_bindings + ) + if len(bindings) != command.count: + raise RuntimeError("native HTTP plan command omitted receipt bindings") + calls = native.consume_http_plan_command(command, self._origin) + results: list[GatewayResult] = [] + receipts: list[HttpReceipt] = [] + for index, (call, binding) in enumerate(zip(calls, bindings)): + member_key = f"{idempotency_key}:{index}" + try: + results.append(await self._executor(_gateway_request(call))) + except asyncio.CancelledError: + raise HttpGatewayCancelled( + _receipt( + member_key, + binding, + plan_commitment, + "cancelled", + ), + tuple(receipts), + ) from None + except Exception: + raise HttpGatewayError( + _receipt( + member_key, + binding, + plan_commitment, + "outcome-unknown", + ), + tuple(receipts), + ) from None + receipts.append( + _receipt( + member_key, + binding, + plan_commitment, + "succeeded", + ) + ) + return tuple(results), tuple(receipts) + + +class HttpFacade: + def profile(self, *, scheme: str, authority: str) -> HttpProfile: + return HttpProfile(scheme=scheme, authority=authority) + + +http = HttpFacade() + + +async def _authorize_http( + agent: AttachedAgent, + action: HttpAction, + request: Optional[HttpAuthorizationRequest], + approval_override: Optional[ApprovalConfiguration] = None, +) -> HttpAuthorizationResult: + agent._assert_active() + if type(action) is not HttpAction or type(agent._profile) is not HttpProfile: + raise HttpProfileError("profile-mismatch", "attached agent does not use the HTTP profile") + if action.profile is not agent._profile: + raise HttpProfileError("profile-mismatch", "HTTP action belongs to another profile") + request = HttpAuthorizationRequest() if request is None else request + if type(request) is not HttpAuthorizationRequest: + raise TypeError("request must be an HttpAuthorizationRequest") + if not agent._grant_chain: + raise HttpProfileError("disposed", "attached authority is unavailable") + prepared = native.prepare_http_action( + action._call, + agent.identity.principal.principal, + agent._grant_chain[-1].signed_grant, + request.challenge, + request.evaluation_time, + ) + approval_configuration = agent._approval if approval_override is None else approval_override + signed = await _SigningCoordinator().execute( + unsigned=prepared.unsigned, + principal=agent.identity.principal, + signer=agent._signer, + approval=approval_configuration, + required_approval=agent._client._configured_authority.required_approval, + expires_at=_transaction_expiry(approval_configuration.policy.expires_in_seconds), + display=tuple(ReviewField(label, value) for label, value in prepared.review_fields), + ) + native_result, command = native.authorize_http( + prepared, + signed.signed_object, + [value.signed_grant for value in agent._grant_chain], + [[_native_evidence(evidence) for evidence in value.evidence] for value in agent._grant_chain], + [_native_evidence(value) for value in signed.evidence], + agent._client._configured_authority.context, + ) + metrics = HttpAuthorizationMetrics(*native_result.metrics) + approval = HttpApproval( + approval_configuration.policy.reference.policy_id, + approval_configuration.policy.reference.evaluator_version, + bytes(agent._client._configured_authority.required_approval.configuration_digest), + bytes(approval_configuration.policy.reference.configuration_digest), + approval_configuration.policy.mode, + approval_configuration.policy.max_uses, + signed.transaction_digest, + ) + explanation = _explanation(native_result.kind, native_result.code) + required = native_result.required_configuration + local = bytes(native_result.local_configuration) + encoded = bytes(native_result.result_cbor) + if native_result.kind == "authorized": + if command is None: + raise HttpProfileError("native-authorization-failed", "native HTTP authorization omitted its command") + canonical = bytes(native.inspect_http_action(prepared)) + commitment = bytes(native.commit_canonical_v1("auths.canonical-action.v1", canonical)) + return HttpAuthorized( + "authorized", + native_result.code, + native_result.stage, + explanation, + metrics, + approval, + required, + local, + encoded, + commitment, + command, + ) + if command is not None: + raise HttpProfileError("native-authorization-failed", "failed HTTP decision returned a command") + if native_result.kind == "denied": + return HttpDenied( + "denied", + native_result.code, + native_result.stage, + explanation, + metrics, + approval, + required, + local, + encoded, + ) + return HttpIndeterminate( + "indeterminate", + native_result.code, + native_result.stage, + explanation, + metrics, + approval, + required, + local, + encoded, + ) + + +async def _authorize_http_plan( + agent: AttachedAgent, + plan: HttpPlan, + approval_provider: Optional[ApprovalProvider], + requests: Optional[Sequence[HttpAuthorizationRequest]] = None, +) -> HttpPlanAuthorizationResult: + agent._assert_active() + if type(plan) is not HttpPlan or plan._profile is not agent._profile: + raise HttpProfileError("profile-mismatch", "HTTP plan belongs to another profile") + approval = agent._approval + if approval.policy.mode != "plan-once" or approval.policy.max_uses != plan.length: + raise HttpProfileError("approval-policy-mismatch", "plan-once approval must match the HTTP plan length") + provider = approval.provider if approval_provider is None else approval_provider + request_values = tuple(HttpAuthorizationRequest() for _ in plan._actions) if requests is None else tuple(requests) + if len(request_values) != plan.length or any(type(value) is not HttpAuthorizationRequest for value in request_values): + raise ValueError("authorization requests must match the HTTP plan length") + _validate_plan(plan) + expires_at = int(time.time()) + approval.policy.expires_in_seconds + plan_approval = native.commit_plan_approval( + plan._commitment, + approval.policy.reference.configuration_digest, + approval.policy.max_uses, + expires_at, + ) + session = PlanApprovalSession( + plan_approval=bytes(plan_approval), + member_commitments=plan._member_commitments, + approval=approval, + provider=provider, + expires_at=expires_at, + display=(ReviewField("Profile", "auths.http/1"), ReviewField("Actions", str(plan.length))), + ) + results: list[HttpAuthorizationResult] = [] + try: + for index, (action, request) in enumerate(zip(plan._actions, request_values)): + _validate_plan(plan) + member_approval = ApprovalConfiguration( + approval.policy, + session.provider_for(index, plan._member_commitments[index]), + ) + result = await _authorize_http(agent, action, request, member_approval) + results.append(result) + if isinstance(result, HttpDenied): + return HttpPlanDenied("denied", index, result, tuple(results)) + if isinstance(result, HttpIndeterminate): + return HttpPlanIndeterminate("indeterminate", index, result, tuple(results)) + authorized = tuple(value for value in results if isinstance(value, HttpAuthorized)) + command = native.seal_http_plan_command( + [value.command for value in authorized], plan._profile.origin, plan._commitment + ) + return HttpPlanAuthorized("authorized", command, authorized) + finally: + session.dispose() + + +def _validate_plan(plan: HttpPlan) -> None: + projection = native.commit_http_plan([action._call for action in plan._actions]) + if not native.commitments_equal_v1(bytes(projection.commitment), plan._commitment): + raise HttpProfileError("invalid-profile", "HTTP plan membership changed") + if len(projection.members) != len(plan._member_commitments): + raise HttpProfileError("invalid-profile", "HTTP plan membership changed") + for actual, expected in zip(projection.members, plan._member_commitments): + if not native.commitments_equal_v1(bytes(actual), expected): + raise HttpProfileError("invalid-profile", "HTTP plan membership changed") + + +def _gateway_request(value: native.HttpGatewayRequest) -> HttpGatewayRequest: + return HttpGatewayRequest( + value.method, + value.scheme, + value.authority, + value.path, + tuple((key, tuple(items)) for key, items in value.query), + tuple(value.headers), + value.content_type, + value.body_digest, + ) + + +def _native_evidence(value: ControlEvidence) -> Tuple[str, str, bytes]: + return value.evidence_type, value.media_type, value.bytes + + +def _receipt( + idempotency_key: str, + binding: Tuple[bytes, bytes, bytes], + plan_commitment: Optional[bytes], + outcome: HttpOutcome, +) -> HttpReceipt: + return HttpReceipt( + idempotency_key, + binding[0], + binding[1], + binding[2], + plan_commitment, + cast(HttpExecutionState, native.runtime_execution_state_v1(outcome)), + outcome, + int(time.time()), + ) + + +def _explanation(kind: str, code: str) -> HttpExplanation: + if kind == "authorized": + message = "the proof establishes exact authority for this HTTP request" + elif kind == "denied": + message = "the supplied proof does not authorize this exact HTTP request" + else: + message = "a required trustworthy fact or implementation is unavailable" + return HttpExplanation(code, message, kind == "indeterminate") + + +__all__ = [ + "HttpAction", + "HttpAuthorizationRequest", + "HttpAuthorizationResult", + "HttpAuthorized", + "HttpDenied", + "HttpExplanation", + "HttpGateway", + "HttpGatewayCancelled", + "HttpGatewayError", + "HttpGatewayRequest", + "HttpIndeterminate", + "HttpPlan", + "HttpPlanAuthority", + "HttpPlanAuthorizationResult", + "HttpPlanAuthorized", + "HttpPlanDenied", + "HttpPlanIndeterminate", + "HttpProfile", + "HttpProfileError", + "HttpReceipt", + "HttpReview", + "http", +] diff --git a/bindings/python/python/auths/profiles/mcp.py b/bindings/python/python/auths/profiles/mcp.py new file mode 100644 index 00000000..b3cfa214 --- /dev/null +++ b/bindings/python/python/auths/profiles/mcp.py @@ -0,0 +1,851 @@ +"""Profile-bound MCP authorization and execution.""" + +from __future__ import annotations + +import asyncio +import json +import time +from dataclasses import dataclass, field +from typing import ( + Awaitable, + Callable, + Generic, + Literal, + Mapping, + Optional, + Sequence, + Tuple, + TypeVar, + Union, + cast, +) + +from .. import _native as native +from .._plan import PlanApprovalSession +from ..workflow import ( + ApprovalConfiguration, + ApprovalProvider, + AttachedAgent, + AuthsWorkflowError, + ControlEvidence, + Permission, + Profile, + ReviewField, + _SigningCoordinator, + _transaction_expiry, +) + +VerificationStage = Literal[ + "decode", "resolve", "principal-control", "authority", "complete" +] +McpOutcome = Literal["succeeded", "failed", "cancelled", "outcome-unknown"] +McpExecutionState = Literal["committed", "outcome-unknown"] + +_PLAN_TOKEN = object() + + +@dataclass(frozen=True) +class AuthorizationRequest: + challenge: bytes = field(default_factory=native.generate_challenge_v1) + evaluation_time: int = field(default_factory=lambda: int(time.time())) + + def __post_init__(self) -> None: + challenge = bytes(self.challenge) + if len(challenge) != 32: + raise ValueError("authorization challenge must contain 32 bytes") + if ( + type(self.evaluation_time) is not int + or self.evaluation_time < 0 + or self.evaluation_time > (1 << 64) - 1 + ): + raise ValueError("invalid authorization evaluation time") + object.__setattr__(self, "challenge", challenge) + + +@dataclass(frozen=True) +class McpReview: + title: str + fields: Tuple[ReviewField, ...] + action_commitment: bytes + + +class McpProfile(Profile): + service: str + + def __init__(self, service: str) -> None: + super().__init__("auths.mcp", 1) + try: + native.validate_mcp_service(service) + except (TypeError, ValueError): + raise ValueError("invalid MCP service") + object.__setattr__(self, "service", service) + + def call(self, name: str, arguments: Mapping[str, object]) -> McpAction: + try: + encoded = json.dumps( + dict(arguments), + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + ).encode() + native_call = native.mcp_call(self.service, name, encoded) + except (TypeError, ValueError): + raise ValueError("invalid MCP tool call") from None + return McpAction(self, native_call) + + def review(self, action: McpAction) -> McpReview: + if type(action) is not McpAction or action.profile is not self: + raise AuthsWorkflowError( + "profile-mismatch", "MCP action belongs to another profile" + ) + title, fields, commitment = native.review_mcp_call(action._call) + return McpReview( + title, + tuple(ReviewField(label, value) for label, value in fields), + bytes(commitment), + ) + + def plan(self, actions: Sequence[McpAction]) -> McpPlan: + values = tuple(actions) + if not values or len(values) > 256: + raise AuthsWorkflowError( + "invalid-profile", "MCP plan action count is outside bounds" + ) + if any( + type(action) is not McpAction or action.profile is not self + for action in values + ): + raise AuthsWorkflowError( + "invalid-profile", "MCP plan contains an action from another profile" + ) + try: + projection = native.commit_mcp_plan([action._call for action in values]) + except (TypeError, ValueError): + raise AuthsWorkflowError( + "invalid-profile", "native MCP profile rejected the plan" + ) from None + authority = McpPlanAuthority( + permissions=tuple( + Permission(capability, resource) + for capability, resource in projection.permissions + ), + resource_namespaces=tuple(projection.resource_namespaces), + audiences=tuple(projection.audiences), + ) + return McpPlan( + _PLAN_TOKEN, + self, + values, + bytes(projection.commitment), + tuple(bytes(member) for member in projection.members), + authority, + ) + + def gateway( + self, executor: Callable[[McpGatewayCall], Awaitable[GatewayResult]] + ) -> McpGateway[GatewayResult]: + if not callable(executor): + raise TypeError("MCP gateway executor must be callable") + return McpGateway(self.service, executor) + + +class McpAction: + def __init__(self, profile: McpProfile, call: native.McpCall) -> None: + self._profile = profile + self._call = call + + @property + def profile(self) -> McpProfile: + return self._profile + + @property + def service(self) -> str: + return self._call.service + + @property + def name(self) -> str: + return self._call.name + + +@dataclass(frozen=True) +class McpPlanAuthority: + permissions: Tuple[Permission, ...] + resource_namespaces: Tuple[str, ...] + audiences: Tuple[str, ...] + + +class McpPlan: + def __init__( + self, + token: object, + profile: McpProfile, + actions: Tuple[McpAction, ...], + commitment: bytes, + member_commitments: Tuple[bytes, ...], + authority: McpPlanAuthority, + ) -> None: + if token is not _PLAN_TOKEN: + raise TypeError("sealed Auths MCP plan") + self._profile = profile + self._actions = actions + self._commitment = commitment + self._member_commitments = member_commitments + self._authority = authority + + @property + def length(self) -> int: + return len(self._actions) + + @property + def commitment(self) -> bytes: + return self._commitment + + @property + def authority(self) -> McpPlanAuthority: + return self._authority + + +@dataclass(frozen=True) +class AuthorizationMetrics: + proof_bytes: int + action_bytes: int + context_bytes: int + object_count: int + plan_leaves: int + plan_depth: int + work_units: int + + +@dataclass(frozen=True) +class AuthorizationExplanation: + code: str + message: str + retryable: bool + + +@dataclass(frozen=True) +class ApprovalSummary: + policy_id: str + evaluator_version: str + required_configuration: bytes + executed_configuration: bytes + executed_mode: str + executed_max_uses: int + executed_expires_in_seconds: int + executed_requirements: Tuple[str, ...] + transaction_digest: bytes + decision: Literal["approved"] + + +@dataclass(frozen=True) +class McpAuthorized: + kind: Literal["authorized"] + code: str + stage: VerificationStage + explanation: AuthorizationExplanation + metrics: AuthorizationMetrics + approval: ApprovalSummary + required_configuration: Optional[bytes] + local_configuration: bytes + result_cbor: bytes + action_commitment: bytes + command: native.McpCommand + + +@dataclass(frozen=True) +class McpDenied: + kind: Literal["denied"] + code: str + stage: VerificationStage + explanation: AuthorizationExplanation + metrics: AuthorizationMetrics + approval: ApprovalSummary + required_configuration: Optional[bytes] + local_configuration: bytes + result_cbor: bytes + + +@dataclass(frozen=True) +class McpIndeterminate: + kind: Literal["indeterminate"] + code: str + stage: VerificationStage + explanation: AuthorizationExplanation + metrics: AuthorizationMetrics + approval: ApprovalSummary + required_configuration: Optional[bytes] + local_configuration: bytes + result_cbor: bytes + + +McpAuthorizationResult = Union[McpAuthorized, McpDenied, McpIndeterminate] + + +@dataclass(frozen=True) +class McpPlanMemberAuthorized: + kind: Literal["authorized"] + code: str + stage: VerificationStage + explanation: AuthorizationExplanation + metrics: AuthorizationMetrics + approval: ApprovalSummary + required_configuration: Optional[bytes] + local_configuration: bytes + result_cbor: bytes + action_commitment: bytes + + +McpPlanMemberResult = Union[McpPlanMemberAuthorized, McpDenied, McpIndeterminate] + + +@dataclass(frozen=True) +class McpPlanAuthorized: + kind: Literal["authorized"] + command: native.McpPlanCommand + results: Tuple[McpPlanMemberAuthorized, ...] + + +@dataclass(frozen=True) +class McpPlanDenied: + kind: Literal["denied"] + failed_index: int + result: McpDenied + results: Tuple[McpPlanMemberResult, ...] + + +@dataclass(frozen=True) +class McpPlanIndeterminate: + kind: Literal["indeterminate"] + failed_index: int + result: McpIndeterminate + results: Tuple[McpPlanMemberResult, ...] + + +McpPlanAuthorizationResult = Union[ + McpPlanAuthorized, McpPlanDenied, McpPlanIndeterminate +] + + +@dataclass(frozen=True) +class McpGatewayCall: + service: str + name: str + arguments_json: bytes + + +@dataclass(frozen=True) +class McpReceipt: + idempotency_key: str + command_commitment: bytes + authority_commitment: bytes + context_commitment: bytes + plan_commitment: Optional[bytes] + state_claim: McpExecutionState + outcome: McpOutcome + observed_at: int + + +class McpGatewayError(AuthsWorkflowError): + def __init__( + self, + receipt: McpReceipt, + completed_receipts: Tuple[McpReceipt, ...] = (), + ) -> None: + super().__init__( + "gateway-failed", + "MCP gateway execution outcome is unknown", + operation="execute", + stage="provider", + retry="unknown", + effect_state="outcome-unknown", + remediation="reconcile the idempotency key before another execution attempt", + ) + self.receipt = receipt + self.completed_receipts = completed_receipts + + +class McpGatewayCancelled(AuthsWorkflowError): + def __init__( + self, + receipt: McpReceipt, + completed_receipts: Tuple[McpReceipt, ...] = (), + ) -> None: + super().__init__( + "gateway-cancelled", + "MCP gateway task was cancelled after provider entry", + operation="execute", + stage="provider", + retry="unknown", + effect_state="outcome-unknown", + remediation="reconcile the idempotency key before another execution attempt", + ) + self.receipt = receipt + self.completed_receipts = completed_receipts + + +GatewayResult = TypeVar("GatewayResult") + + +class McpGateway(Generic[GatewayResult]): + def __init__( + self, + service: str, + executor: Callable[[McpGatewayCall], Awaitable[GatewayResult]], + ) -> None: + self._service = service + self._executor = executor + + async def execute( + self, command: native.McpCommand, *, idempotency_key: str + ) -> Tuple[GatewayResult, McpReceipt]: + if type(command) is not native.McpCommand or not idempotency_key: + raise TypeError("gateway requires a native MCP command and idempotency key") + binding = ( + bytes(command.action_commitment), + bytes(command.authority_commitment), + bytes(command.context_commitment), + ) + try: + call = native.consume_mcp_command(command, self._service) + except (TypeError, RuntimeError): + raise + try: + result = await self._executor( + McpGatewayCall( + service=call.service, + name=call.name, + arguments_json=bytes(call.arguments_json), + ) + ) + except asyncio.CancelledError: + raise McpGatewayCancelled( + _receipt(idempotency_key, binding, None, "cancelled") + ) from None + except Exception: + raise McpGatewayError( + _receipt( + idempotency_key, + binding, + None, + "outcome-unknown", + ) + ) from None + return result, _receipt( + idempotency_key, binding, None, "succeeded" + ) + + async def execute_plan( + self, command: native.McpPlanCommand, *, idempotency_key: str + ) -> Tuple[Tuple[GatewayResult, ...], Tuple[McpReceipt, ...]]: + if type(command) is not native.McpPlanCommand or not idempotency_key: + raise TypeError("gateway requires a native MCP plan command and idempotency key") + plan_commitment = bytes(command.plan_commitment) + bindings = tuple( + (bytes(action), bytes(authority), bytes(context)) + for action, authority, context in command.receipt_bindings + ) + if len(bindings) != command.count: + raise RuntimeError("native MCP plan command omitted receipt bindings") + calls = native.consume_mcp_plan_command(command, self._service) + results: list[GatewayResult] = [] + receipts: list[McpReceipt] = [] + for index, (call, binding) in enumerate(zip(calls, bindings)): + member_key = f"{idempotency_key}:{index}" + try: + results.append( + await self._executor( + McpGatewayCall( + service=call.service, + name=call.name, + arguments_json=bytes(call.arguments_json), + ) + ) + ) + receipts.append( + _receipt( + member_key, + binding, + plan_commitment, + "succeeded", + ) + ) + except asyncio.CancelledError: + raise McpGatewayCancelled( + _receipt( + member_key, + binding, + plan_commitment, + "cancelled", + ), + tuple(receipts), + ) from None + except Exception: + raise McpGatewayError( + _receipt( + member_key, + binding, + plan_commitment, + "outcome-unknown", + ), + tuple(receipts), + ) from None + return tuple(results), tuple(receipts) + + +class McpFacade: + def profile(self, *, service: str) -> McpProfile: + return McpProfile(service) + + +mcp = McpFacade() + + +async def _authorize_mcp( + agent: AttachedAgent, + action: McpAction, + request: Optional[AuthorizationRequest], + approval_override: Optional[ApprovalConfiguration] = None, +) -> McpAuthorizationResult: + agent._assert_active() + if type(action) is not McpAction: + raise TypeError("action must be an MCP action") + if not isinstance(agent._profile, McpProfile): + raise AuthsWorkflowError( + "profile-mismatch", "attached agent does not use the MCP profile" + ) + if action.profile is not agent._profile: + raise AuthsWorkflowError( + "profile-mismatch", "MCP action belongs to a different profile instance" + ) + request = AuthorizationRequest() if request is None else request + if type(request) is not AuthorizationRequest: + raise TypeError("request must be an AuthorizationRequest") + if not agent._grant_chain: + raise AuthsWorkflowError("disposed", "attached authority is unavailable") + try: + prepared = native.prepare_mcp_call_action( + action._call, + agent.identity.principal.principal, + agent._grant_chain[-1].signed_grant, + request.challenge, + request.evaluation_time, + ) + except (TypeError, ValueError): + raise AuthsWorkflowError( + "invalid-action", "native MCP profile rejected the action" + ) from None + approval_configuration = ( + agent._approval if approval_override is None else approval_override + ) + signed = await _SigningCoordinator().execute( + unsigned=prepared.unsigned, + principal=agent.identity.principal, + signer=agent._signer, + approval=approval_configuration, + required_approval=agent._client._configured_authority.required_approval, + expires_at=_transaction_expiry( + approval_configuration.policy.expires_in_seconds + ), + display=tuple( + ReviewField(label, value) for label, value in prepared.review_fields + ), + ) + grant_evidence = [ + [_native_evidence(value) for value in material.evidence] + for material in agent._grant_chain + ] + try: + native_result, command = native.authorize_mcp( + prepared, + signed.signed_object, + [material.signed_grant for material in agent._grant_chain], + grant_evidence, + [_native_evidence(value) for value in signed.evidence], + agent._client._configured_authority.context, + ) + except (TypeError, ValueError, RuntimeError): + raise AuthsWorkflowError( + "native-authorization-failed", "native MCP authorization failed" + ) from None + metrics = AuthorizationMetrics(*native_result.metrics) + kind = native_result.kind + approval = ApprovalSummary( + policy_id=approval_configuration.policy.reference.policy_id, + evaluator_version=approval_configuration.policy.reference.evaluator_version, + required_configuration=bytes( + agent._client._configured_authority.required_approval.configuration_digest + ), + executed_configuration=bytes( + approval_configuration.policy.reference.configuration_digest + ), + executed_mode=approval_configuration.policy.mode, + executed_max_uses=approval_configuration.policy.max_uses, + executed_expires_in_seconds=approval_configuration.policy.expires_in_seconds, + executed_requirements=approval_configuration.policy.requirements, + transaction_digest=signed.transaction_digest, + decision="approved", + ) + explanation = _explanation(kind, native_result.code) + stage = native_result.stage + if kind == "authorized": + if command is None: + raise AuthsWorkflowError( + "native-authorization-failed", + "native MCP authorization omitted its sealed command", + ) + canonical_action, _ = native.inspect_mcp_action(prepared) + action_commitment = native.commit_canonical_v1( + "auths.canonical-action.v1", canonical_action + ) + return McpAuthorized( + kind="authorized", + code=native_result.code, + stage=stage, + explanation=explanation, + metrics=metrics, + approval=approval, + required_configuration=native_result.required_configuration, + local_configuration=bytes(native_result.local_configuration), + result_cbor=bytes(native_result.result_cbor), + action_commitment=bytes(action_commitment), + command=command, + ) + if command is not None: + raise AuthsWorkflowError( + "native-authorization-failed", + "native MCP authorization returned a command for a failed verdict", + ) + if kind == "denied": + return McpDenied( + kind="denied", + code=native_result.code, + stage=stage, + explanation=explanation, + metrics=metrics, + approval=approval, + required_configuration=native_result.required_configuration, + local_configuration=bytes(native_result.local_configuration), + result_cbor=bytes(native_result.result_cbor), + ) + return McpIndeterminate( + kind="indeterminate", + code=native_result.code, + stage=stage, + explanation=explanation, + metrics=metrics, + approval=approval, + required_configuration=native_result.required_configuration, + local_configuration=bytes(native_result.local_configuration), + result_cbor=bytes(native_result.result_cbor), + ) + + +async def _authorize_mcp_plan( + agent: AttachedAgent, + plan: McpPlan, + approval_provider: Optional[ApprovalProvider], + requests: Optional[Sequence[AuthorizationRequest]] = None, +) -> McpPlanAuthorizationResult: + agent._assert_active() + if type(plan) is not McpPlan or plan._profile is not agent._profile: + raise AuthsWorkflowError( + "profile-mismatch", "MCP plan belongs to a different profile instance" + ) + approval = agent._approval + if approval.policy.mode != "plan-once" or approval.policy.max_uses != plan.length: + raise AuthsWorkflowError( + "approval-policy-mismatch", + "plan-once approval must match the exact MCP plan length", + ) + provider = approval.provider if approval_provider is None else approval_provider + if not callable(getattr(provider, "approve", None)): + raise TypeError("approval provider is invalid") + request_values = ( + tuple(AuthorizationRequest() for _ in plan._actions) + if requests is None + else tuple(requests) + ) + if len(request_values) != plan.length or any( + type(request) is not AuthorizationRequest for request in request_values + ): + raise ValueError("authorization requests must match the MCP plan length") + _validate_plan_members(plan) + started_at = int(time.time()) + expires_at = started_at + approval.policy.expires_in_seconds + try: + plan_approval = native.commit_plan_approval( + plan._commitment, + approval.policy.reference.configuration_digest, + approval.policy.max_uses, + expires_at, + ) + except (TypeError, ValueError): + raise AuthsWorkflowError( + "invalid-profile", "native authoring rejected the MCP plan approval" + ) from None + session = PlanApprovalSession( + plan_approval=bytes(plan_approval), + member_commitments=plan._member_commitments, + approval=approval, + provider=provider, + expires_at=expires_at, + display=( + ReviewField("Profile", plan._profile.id + "/" + str(plan._profile.version)), + ReviewField("Actions", str(plan.length)), + ), + ) + results: list[McpAuthorizationResult] = [] + try: + for index, (action, request) in enumerate(zip(plan._actions, request_values)): + _validate_plan_members(plan) + member_approval = ApprovalConfiguration( + approval.policy, + session.provider_for(index, plan._member_commitments[index]), + ) + result = await _authorize_mcp(agent, action, request, member_approval) + results.append(result) + if isinstance(result, McpDenied): + return McpPlanDenied( + "denied", + index, + result, + tuple(_plan_member_result(value) for value in results), + ) + if isinstance(result, McpIndeterminate): + return McpPlanIndeterminate( + "indeterminate", + index, + result, + tuple(_plan_member_result(value) for value in results), + ) + authorized = tuple( + result for result in results if isinstance(result, McpAuthorized) + ) + if len(authorized) != plan.length: + raise AuthsWorkflowError( + "native-authorization-failed", + "MCP plan omitted an authorized member", + ) + try: + command = native.seal_mcp_plan_command( + [result.command for result in authorized], + plan._profile.service, + plan._commitment, + ) + except (TypeError, ValueError, RuntimeError): + raise AuthsWorkflowError( + "native-authorization-failed", + "native MCP profile rejected the verified plan", + ) from None + return McpPlanAuthorized( + "authorized", + command, + tuple(_authorized_member(result) for result in authorized), + ) + finally: + session.dispose() + + +def _validate_plan_members(plan: McpPlan) -> None: + try: + projection = native.commit_mcp_plan([action._call for action in plan._actions]) + except (TypeError, ValueError): + raise AuthsWorkflowError( + "invalid-profile", "native MCP profile rejected the plan" + ) from None + if not native.commitments_equal_v1( + bytes(projection.commitment), plan._commitment + ) or len(projection.members) != len(plan._member_commitments): + raise AuthsWorkflowError( + "invalid-profile", "MCP plan membership changed after construction" + ) + for actual, expected in zip(projection.members, plan._member_commitments): + if not native.commitments_equal_v1(bytes(actual), expected): + raise AuthsWorkflowError( + "invalid-profile", "MCP plan membership changed after construction" + ) + + +def _authorized_member(result: McpAuthorized) -> McpPlanMemberAuthorized: + return McpPlanMemberAuthorized( + kind="authorized", + code=result.code, + stage=result.stage, + explanation=result.explanation, + metrics=result.metrics, + approval=result.approval, + required_configuration=result.required_configuration, + local_configuration=result.local_configuration, + result_cbor=result.result_cbor, + action_commitment=result.action_commitment, + ) + + +def _plan_member_result(result: McpAuthorizationResult) -> McpPlanMemberResult: + if isinstance(result, McpAuthorized): + return _authorized_member(result) + return result + + +def _native_evidence(value: ControlEvidence) -> Tuple[str, str, bytes]: + return value.evidence_type, value.media_type, value.bytes + + +def _receipt( + idempotency_key: str, + binding: Tuple[bytes, bytes, bytes], + plan_commitment: Optional[bytes], + outcome: McpOutcome, +) -> McpReceipt: + return McpReceipt( + idempotency_key, + binding[0], + binding[1], + binding[2], + plan_commitment, + cast(McpExecutionState, native.runtime_execution_state_v1(outcome)), + outcome, + int(time.time()), + ) + + +def _explanation( + kind: Literal["authorized", "denied", "indeterminate"], code: str +) -> AuthorizationExplanation: + if kind == "authorized": + message = "the proof establishes exact authority for this MCP tool call" + elif kind == "denied": + message = "the supplied proof does not authorize this exact MCP tool call" + else: + message = "a required trustworthy fact or implementation is unavailable" + return AuthorizationExplanation(code, message, kind == "indeterminate") + + +__all__ = [ + "ApprovalSummary", + "AuthorizationExplanation", + "AuthorizationMetrics", + "AuthorizationRequest", + "McpAction", + "McpAuthorizationResult", + "McpAuthorized", + "McpDenied", + "McpFacade", + "McpGateway", + "McpGatewayCancelled", + "McpGatewayCall", + "McpGatewayError", + "McpIndeterminate", + "McpPlan", + "McpPlanAuthority", + "McpPlanAuthorizationResult", + "McpPlanAuthorized", + "McpPlanDenied", + "McpPlanIndeterminate", + "McpPlanMemberAuthorized", + "McpPlanMemberResult", + "McpProfile", + "McpReceipt", + "McpReview", + "mcp", +] diff --git a/bindings/python/python/auths/runtime.py b/bindings/python/python/auths/runtime.py new file mode 100644 index 00000000..9571a967 --- /dev/null +++ b/bindings/python/python/auths/runtime.py @@ -0,0 +1,327 @@ +"""Rust-owned execution lifecycle with replaceable state and effect ports.""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass +from types import MappingProxyType +from typing import ( + Generic, + Literal, + Mapping, + Optional, + Protocol, + Tuple, + TypeVar, + Union, + cast, + runtime_checkable, +) + +from ._native import ( + runtime_additive_capacity_v1, + runtime_exclusive_capacity_v1, + runtime_replay_v1, + runtime_transition_v1, +) + +LifecycleState = Literal[ + "decision-recorded", + "reserved", + "execution-intent-recorded", + "executing", + "committed", + "released", + "outcome-unknown", + "reconciled-committed", + "reconciled-released", +] +RuntimeOperation = Literal[ + "record-decision", + "reserve", + "record-execution-intent", + "authorize-credential", + "start-attempt", + "mark-provider-call-entered", + "commit", + "release", + "mark-outcome-unknown", + "reconcile-effect", + "reconcile-non-effect", + "reconcile-inconclusive", +] +ReplayClass = Literal["absent", "exact-replay", "conflict"] +ChallengeClaim = Literal["claimed", "duplicate", "expired", "missing"] +BudgetReservation = Literal["reserved", "duplicate", "exhausted", "unavailable"] + + +@dataclass(frozen=True) +class TransitionGates: + core_authorized: bool = False + policy_eligible: bool = False + configuration_matches: bool = False + not_revoked: bool = False + not_expired: bool = False + capacity_available: bool = False + execution_intent_present: bool = False + credential_authorized: bool = False + attempt_present: bool = False + provider_call_entered: bool = False + cancellation_allowed: bool = False + definite_effect: bool = False + definite_non_effect: bool = False + reconciliation_fresh: bool = False + reconciliation_matches: bool = False + + +@dataclass(frozen=True) +class RuntimeApplied: + kind: Literal["applied", "observation-only"] + state: LifecycleState + + +@dataclass(frozen=True) +class RuntimeRejected: + kind: Literal["rejected"] + code: str + + +RuntimeTransition = Union[RuntimeApplied, RuntimeRejected] + + +class RuntimeKernel: + def transition( + self, + current: Optional[LifecycleState], + operation: RuntimeOperation, + gates: TransitionGates, + ) -> RuntimeTransition: + kind, value = runtime_transition_v1( + current, + operation, + gates.core_authorized, + gates.policy_eligible, + gates.configuration_matches, + gates.not_revoked, + gates.not_expired, + gates.capacity_available, + gates.execution_intent_present, + gates.credential_authorized, + gates.attempt_present, + gates.provider_call_entered, + gates.cancellation_allowed, + gates.definite_effect, + gates.definite_non_effect, + gates.reconciliation_fresh, + gates.reconciliation_matches, + ) + if kind == "rejected": + if value is None: + raise RuntimeError("native runtime omitted the rejection code") + return RuntimeRejected("rejected", value) + if value is None: + raise RuntimeError("native runtime omitted the applied state") + return RuntimeApplied( + cast(Literal["applied", "observation-only"], kind), + cast(LifecycleState, value), + ) + + def replay(self, record_exists: bool, commitments_equal: bool) -> ReplayClass: + return cast(ReplayClass, runtime_replay_v1(record_exists, commitments_equal)) + + def additive_capacity( + self, *, ceiling: int, committed: int, active: int, requested: int + ) -> bool: + return runtime_additive_capacity_v1(ceiling, committed, active, requested) + + def exclusive_capacity( + self, *, has_live_owner: bool, owner_is_exact_replay: bool + ) -> bool: + return runtime_exclusive_capacity_v1(has_live_owner, owner_is_exact_replay) + + +@dataclass(frozen=True) +class CommandState: + command_id: str + action_commitment: bytes + authority_commitment: bytes + context_commitment: bytes + state: LifecycleState + revision: int + idempotency_key: str + observed_at: int + + def __post_init__(self) -> None: + for name in ("action_commitment", "authority_commitment", "context_commitment"): + value = bytes(getattr(self, name)) + if len(value) != 32: + raise ValueError(name.replace("_", " ") + " must contain 32 bytes") + object.__setattr__(self, name, value) + if not self.command_id or not self.idempotency_key or self.revision < 0: + raise ValueError("invalid command state") + + +@runtime_checkable +class Clock(Protocol): + def now(self) -> int: ... + + +@runtime_checkable +class ChallengeStore(Protocol): + async def issue(self, challenge: bytes, *, expires_at: int) -> bool: ... + async def claim(self, challenge: bytes, *, now: int) -> ChallengeClaim: ... + + +@runtime_checkable +class BudgetStore(Protocol): + async def reserve( + self, action_commitment: bytes, algebra: str, amount: int + ) -> BudgetReservation: ... + + +@runtime_checkable +class ReceiptStore(Protocol): + async def put(self, receipt_id: str, receipt: bytes) -> Literal["stored", "duplicate"]: ... + + +@runtime_checkable +class CommandStore(Protocol): + async def load(self, command_id: str) -> Optional[CommandState]: ... + async def compare_and_swap( + self, expected_revision: Optional[int], state: CommandState + ) -> Literal["stored", "conflict"]: ... + + +CommandT = TypeVar("CommandT", contravariant=True) +ResultT = TypeVar("ResultT", covariant=True) + + +@runtime_checkable +class ClosedExecutor(Protocol, Generic[CommandT, ResultT]): + async def execute( + self, command: CommandT, *, idempotency_key: str + ) -> ResultT: ... + + +@runtime_checkable +class Reconciler(Protocol, Generic[ResultT]): + async def reconcile(self, idempotency_key: str) -> Optional[ResultT]: ... + + +class SystemClock: + def now(self) -> int: + return int(time.time()) + + +class InMemoryRuntimeStore(CommandStore, ReceiptStore, ChallengeStore, BudgetStore): + def __init__(self, *, budget_ceilings: Optional[Mapping[str, int]] = None) -> None: + self._commands: dict[str, CommandState] = {} + self._receipts: dict[str, bytes] = {} + self._challenges: dict[bytes, Tuple[int, bool]] = {} + self._budget_ceilings = dict(budget_ceilings or {}) + self._budget_used: dict[str, int] = {} + self._budget_reservations: dict[bytes, Tuple[str, int]] = {} + self._lock = asyncio.Lock() + + async def issue(self, challenge: bytes, *, expires_at: int) -> bool: + value = bytes(challenge) + if len(value) != 32 or expires_at < 0: + raise ValueError("invalid challenge") + async with self._lock: + if value in self._challenges: + return False + self._challenges[value] = (expires_at, False) + return True + + async def claim(self, challenge: bytes, *, now: int) -> ChallengeClaim: + value = bytes(challenge) + async with self._lock: + record = self._challenges.get(value) + if record is None: + return "missing" + expires_at, claimed = record + if now > expires_at: + return "expired" + if claimed: + return "duplicate" + self._challenges[value] = (expires_at, True) + return "claimed" + + async def reserve( + self, action_commitment: bytes, algebra: str, amount: int + ) -> BudgetReservation: + commitment = bytes(action_commitment) + if len(commitment) != 32 or not algebra or amount < 0: + raise ValueError("invalid budget reservation") + async with self._lock: + existing = self._budget_reservations.get(commitment) + if existing is not None: + if existing != (algebra, amount): + raise ValueError("action commitment is bound to another reservation") + return "duplicate" + ceiling = self._budget_ceilings.get(algebra) + if ceiling is None: + return "unavailable" + used = self._budget_used.get(algebra, 0) + if not RuntimeKernel().additive_capacity( + ceiling=ceiling, committed=used, active=0, requested=amount + ): + return "exhausted" + self._budget_used[algebra] = used + amount + self._budget_reservations[commitment] = (algebra, amount) + return "reserved" + + async def load(self, command_id: str) -> Optional[CommandState]: + async with self._lock: + return self._commands.get(command_id) + + async def compare_and_swap( + self, expected_revision: Optional[int], state: CommandState + ) -> Literal["stored", "conflict"]: + async with self._lock: + current = self._commands.get(state.command_id) + revision = None if current is None else current.revision + if revision != expected_revision: + return "conflict" + self._commands[state.command_id] = state + return "stored" + + async def put(self, receipt_id: str, receipt: bytes) -> Literal["stored", "duplicate"]: + value = bytes(receipt) + async with self._lock: + current = self._receipts.get(receipt_id) + if current is not None: + if current != value: + raise ValueError("receipt identifier is bound to different bytes") + return "duplicate" + self._receipts[receipt_id] = value + return "stored" + + async def snapshot(self) -> Mapping[str, CommandState]: + async with self._lock: + return MappingProxyType(dict(self._commands)) + + +__all__ = [ + "BudgetStore", + "BudgetReservation", + "ChallengeStore", + "ChallengeClaim", + "Clock", + "ClosedExecutor", + "CommandState", + "CommandStore", + "InMemoryRuntimeStore", + "LifecycleState", + "ReceiptStore", + "Reconciler", + "ReplayClass", + "RuntimeApplied", + "RuntimeKernel", + "RuntimeOperation", + "RuntimeRejected", + "RuntimeTransition", + "SystemClock", + "TransitionGates", +] diff --git a/bindings/python/python/auths/testkit.py b/bindings/python/python/auths/testkit.py new file mode 100644 index 00000000..ce4cd364 --- /dev/null +++ b/bindings/python/python/auths/testkit.py @@ -0,0 +1,210 @@ +"""Deterministic development adapters and executable port checks.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Awaitable, Callable, Generic, TypeVar + +from .approvals import ApprovalDecision, ApprovalProvider, ApprovalRequest, ApprovalResponse +from .custody import ( + PrincipalDescriptor, + Signer, + SignerLifecycle, + SigningRequest, + SigningResponse, +) +from .identity import ( + DecodedIdentity, + IdentityMethod, + ResolutionEvidence, + ResolvedIdentity, + ResolvedIdentityRecord, + VerificationMaterial, +) +from .observability import AuthsEvent + +ADAPTER_CONTRACT_VERSION = 1 + + +class DevelopmentApproval(ApprovalProvider): + def __init__(self, decision: ApprovalDecision = "approved") -> None: + self.decision: ApprovalDecision = decision + self.requests: list[ApprovalRequest] = [] + + async def approve(self, request: ApprovalRequest) -> ApprovalResponse: + self.requests.append(request) + return ApprovalResponse( + request.request_id, + request.transaction_digest, + request.policy, + self.decision, + ) + + +class DevelopmentSigner(Signer): + kind = "auths.testkit.development-signer" + lifecycle: SignerLifecycle = "durable" + + def __init__(self, principal: PrincipalDescriptor, *, signature_byte: int = 7) -> None: + if not 0 <= signature_byte <= 255: + raise ValueError("signature byte must fit in one byte") + self._principal = principal + self._signature_byte = signature_byte + self.requests: list[SigningRequest] = [] + self.closed = False + + async def public_identity(self) -> PrincipalDescriptor: + if self.closed: + raise RuntimeError("development signer is closed") + return self._principal + + async def sign(self, request: SigningRequest) -> SigningResponse: + if self.closed: + raise RuntimeError("development signer is closed") + self.requests.append(request) + return SigningResponse( + request.request_id, + request.principal, + request.transaction_digest, + bytes([self._signature_byte]) * 64, + ) + + async def aclose(self) -> None: + self.closed = True + + +@dataclass +class FixedClock: + value: int + + def now(self) -> int: + return self.value + + def advance(self, seconds: int) -> None: + if seconds < 0: + raise ValueError("clock cannot move backwards") + self.value += seconds + + +class RecordingTelemetry: + def __init__(self) -> None: + self.events: list[AuthsEvent] = [] + + def emit(self, event: AuthsEvent) -> None: + self.events.append(event) + + +class DevelopmentIdentityMethod: + def __init__(self, method_id: str = "auths.test-identity") -> None: + self.method_id = method_id + self.version = 1 + + async def resolve(self, identity: DecodedIdentity) -> ResolvedIdentityRecord: + return ResolvedIdentityRecord( + identity.method_id, + identity.identity_id, + identity.method_material, + identity.relationships, + ResolutionEvidence("development", 0, (1 << 64) - 1, ("testkit",)), + ) + + async def validate(self, identity: ResolvedIdentity) -> None: + if identity.record.method_id != self.method_id: + raise ValueError("development identity method mismatch") + + +class DevelopmentSignatureSuite: + def __init__( + self, + suite_id: str = "auths.test-signature", + *, + signature: bytes = b"auths-development-signature", + ) -> None: + self.suite_id = suite_id + self.version = 1 + self._signature = bytes(signature) + + async def verify( + self, + material: tuple[VerificationMaterial, ...], + preimage: bytes, + signature: bytes, + ) -> None: + if not material or not preimage or signature != self._signature: + raise ValueError("development signature rejected") + + +InputT = TypeVar("InputT") +OutputT = TypeVar("OutputT") + + +class MemoryGateway(Generic[InputT, OutputT]): + def __init__(self, result: Callable[[InputT], Awaitable[OutputT]]) -> None: + self._result = result + self.calls: list[InputT] = [] + + async def __call__(self, value: InputT) -> OutputT: + self.calls.append(value) + return await self._result(value) + + +async def check_signer(signer: Signer) -> PrincipalDescriptor: + first = await signer.public_identity() + second = await signer.public_identity() + if not first.matches(second): + raise AssertionError("signer identity changed between reads") + return first + + +async def check_approval_provider( + provider: ApprovalProvider, request: ApprovalRequest +) -> ApprovalResponse: + result = await provider.approve(request) + if type(result) is not ApprovalResponse: + raise AssertionError("approval provider returned the wrong type") + if result.request_id != request.request_id: + raise AssertionError("approval provider changed the request identity") + return result + + +async def check_identity_method( + method: IdentityMethod, identity: DecodedIdentity +) -> ResolvedIdentityRecord: + result = await method.resolve(identity) + if type(result) is not ResolvedIdentityRecord: + raise AssertionError("identity method returned the wrong resolved type") + if result.method_id != method.method_id or result.identity_id != identity.identity_id: + raise AssertionError("identity method changed the requested identity") + await method.validate(ResolvedIdentity(identity, result)) + return result + + +def check_telemetry(telemetry: RecordingTelemetry) -> AuthsEvent: + event = AuthsEvent( + "auths.testkit", + "conformance", + "telemetry", + "succeeded", + 0, + (("contract_version", ADAPTER_CONTRACT_VERSION),), + ) + telemetry.emit(event) + if telemetry.events != [event]: + raise AssertionError("telemetry adapter changed the event") + return event + + +__all__ = [ + "ADAPTER_CONTRACT_VERSION", + "DevelopmentApproval", + "DevelopmentIdentityMethod", + "DevelopmentSignatureSuite", + "DevelopmentSigner", + "FixedClock", + "MemoryGateway", + "RecordingTelemetry", + "check_approval_provider", + "check_identity_method", + "check_signer", + "check_telemetry", +] diff --git a/bindings/python/python/auths/trust.py b/bindings/python/python/auths/trust.py new file mode 100644 index 00000000..8840e1e9 --- /dev/null +++ b/bindings/python/python/auths/trust.py @@ -0,0 +1,254 @@ +"""Typed trust configuration, evidence, and offline bundles.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Literal, Optional, Protocol, Sequence, Tuple, runtime_checkable + +from . import _native as native +from .authority import ProofPlan, _native_proof_plan +from .lifecycle import GrantStatusSnapshot, PrincipalStatusSnapshot +from .workflow import BudgetCeiling, Permission, Principal, Profile, TrustedAuthority, TrustedAuthoritySnapshot + +AssuranceRole = Literal["root", "intermediate", "actor", "external-issuer"] +AssuranceQuantifier = Literal["any", "every"] + + +@dataclass(frozen=True) +class AssuranceRequirement: + role: AssuranceRole + quantifier: AssuranceQuantifier + claim: str + maximum_age: Optional[int] = None + + +@dataclass(frozen=True) +class AssurancePolicy: + id: str + requirements: Tuple[AssuranceRequirement, ...] + + def __post_init__(self) -> None: + object.__setattr__(self, "requirements", tuple(self.requirements)) + + def _native(self) -> native.AssurancePolicy: + return native.AssurancePolicy( + self.id, + [ + (value.role, value.quantifier, value.claim, value.maximum_age) + for value in self.requirements + ], + ) + + +@dataclass(frozen=True) +class TrustAnchor: + id: str + principal: Principal + accepted_methods: Tuple[str, ...] + profiles: Tuple[Profile, ...] + permissions: Tuple[Permission, ...] + resource_namespaces: Tuple[str, ...] + audiences: Tuple[str, ...] + not_before: int + expires_at: int + max_delegation_depth: int + assurance_policy: str + budget: Optional[BudgetCeiling] = None + status: Optional[Tuple[str, int]] = None + + def __post_init__(self) -> None: + object.__setattr__(self, "accepted_methods", tuple(self.accepted_methods)) + object.__setattr__(self, "profiles", tuple(self.profiles)) + object.__setattr__(self, "permissions", tuple(self.permissions)) + object.__setattr__(self, "resource_namespaces", tuple(self.resource_namespaces)) + object.__setattr__(self, "audiences", tuple(self.audiences)) + + def _native(self) -> native.TrustAnchor: + return native.TrustAnchor( + self.id, + self.principal, + list(self.accepted_methods), + [(value.id, value.version) for value in self.profiles], + [(value.capability, value.resource) for value in self.permissions], + list(self.resource_namespaces), + list(self.audiences), + self.not_before, + self.expires_at, + None if self.budget is None else (self.budget.algebra, self.budget.value), + self.max_delegation_depth, + self.assurance_policy, + self.status, + ) + + +@dataclass(frozen=True) +class EvidenceRequest: + source_id: str + maximum_bytes: int + timeout: float + maximum_redirects: int = 0 + allow_private_network: bool = False + + +@dataclass(frozen=True) +class EvidenceProvenance: + source_id: str + observed_at: int + valid_until: int + version: str + + +@dataclass(frozen=True) +class ResolvedEvidence: + bytes: bytes + media_type: str + provenance: EvidenceProvenance + + def __post_init__(self) -> None: + object.__setattr__(self, "bytes", bytes(self.bytes)) + + +@runtime_checkable +class EvidenceProvider(Protocol): + async def resolve(self, request: EvidenceRequest) -> ResolvedEvidence: ... + + +@dataclass(frozen=True) +class OfflineEvidenceBundle: + evidence: Tuple[ResolvedEvidence, ...] + captured_at: int + + def __post_init__(self) -> None: + values = tuple(self.evidence) + if not values: + raise ValueError("offline evidence bundle cannot be empty") + object.__setattr__(self, "evidence", values) + + +@dataclass(frozen=True) +class CompiledTrust: + context: native.TrustedContext + roots: Tuple[Principal, ...] + offline_evidence: Optional[OfflineEvidenceBundle] + + +@dataclass(frozen=True) +class PolicyReplacement: + current: CompiledTrust + replacement: CompiledTrust + activated_at: int + + +def replace_policy( + current: CompiledTrust, + replacement: CompiledTrust, + *, + activated_at: int, +) -> PolicyReplacement: + if type(current) is not CompiledTrust or type(replacement) is not CompiledTrust: + raise TypeError("policy replacement requires compiled trust values") + if activated_at < 0: + raise ValueError("policy activation time cannot be negative") + if bytes(native.inspect_trusted_context(current.context)) == bytes( + native.inspect_trusted_context(replacement.context) + ): + raise ValueError("replacement policy must have a different configuration") + return PolicyReplacement(current, replacement, activated_at) + + +async def load_evidence( + provider: EvidenceProvider, request: EvidenceRequest +) -> ResolvedEvidence: + if ( + request.maximum_bytes < 1 + or request.maximum_bytes > 16 * 1024 * 1024 + or request.timeout <= 0 + or request.timeout > 300 + or request.maximum_redirects < 0 + or request.maximum_redirects > 8 + ): + raise ValueError("evidence request is outside supported bounds") + result = await asyncio.wait_for(provider.resolve(request), request.timeout) + if type(result) is not ResolvedEvidence: + raise TypeError("evidence provider returned the wrong type") + if ( + len(result.bytes) > request.maximum_bytes + or result.provenance.source_id != request.source_id + or result.provenance.valid_until < result.provenance.observed_at + ): + raise ValueError("evidence provider returned inconsistent evidence") + return result + + +def compile_trust( + *, + anchors: Sequence[TrustAnchor], + assurance: AssurancePolicy, + minimum_authorized_branches: int = 1, + minimum_distinct_actors: int = 1, + minimum_distinct_roots: int = 1, + expected_plan: Optional[ProofPlan] = None, + principal_status: Optional[PrincipalStatusSnapshot] = None, + grant_status: Optional[GrantStatusSnapshot] = None, + channel_policy: str = "none", + evidence_types: Sequence[str] = (), + critical_extensions: Sequence[str] = (), + offline_evidence: Optional[OfflineEvidenceBundle] = None, +) -> CompiledTrust: + anchor_values = tuple(anchors) + if not anchor_values or any(type(value) is not TrustAnchor for value in anchor_values): + raise ValueError("trust requires at least one typed anchor") + context = native.compile_trusted_context( + native.self_contained_configuration(), + None if expected_plan is None else _native_proof_plan(expected_plan), + minimum_authorized_branches, + minimum_distinct_actors, + minimum_distinct_roots, + [value._native() for value in anchor_values], + assurance._native(), + None if principal_status is None else principal_status._native, + None if grant_status is None else grant_status._native, + channel_policy, + list(evidence_types), + list(critical_extensions), + ) + return CompiledTrust( + context, + tuple(value.principal for value in anchor_values), + offline_evidence, + ) + + +StatusSnapshot = native.StatusSnapshot +TrustedContext = native.TrustedContext +compile_trusted_context = native.compile_trusted_context +parse_trusted_context = native.parse_trusted_context +self_contained_configuration = native.self_contained_configuration +status_snapshot = native.status_snapshot + +__all__ = [ + "AssurancePolicy", + "AssuranceQuantifier", + "AssuranceRequirement", + "AssuranceRole", + "CompiledTrust", + "EvidenceProvenance", + "EvidenceProvider", + "EvidenceRequest", + "OfflineEvidenceBundle", + "PolicyReplacement", + "ResolvedEvidence", + "StatusSnapshot", + "TrustAnchor", + "TrustedAuthority", + "TrustedAuthoritySnapshot", + "TrustedContext", + "compile_trust", + "compile_trusted_context", + "load_evidence", + "parse_trusted_context", + "replace_policy", + "self_contained_configuration", + "status_snapshot", +] diff --git a/bindings/python/python/auths/verify.py b/bindings/python/python/auths/verify.py new file mode 100644 index 00000000..8e52ac8b --- /dev/null +++ b/bindings/python/python/auths/verify.py @@ -0,0 +1,156 @@ +"""Deterministic, effect-free Auths verification.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable, Literal, Optional, Tuple, Union + +from ._native import NativeVerificationResult, verify_many_v1, verify_v1 + +VerdictKind = Literal["authorized", "denied", "indeterminate"] +VerificationStage = Literal[ + "decode", "resolve", "principal-control", "authority", "complete" +] + + +@dataclass(frozen=True) +class Explanation: + code: str + message: str + retryable: bool + + +@dataclass(frozen=True) +class VerificationMetrics: + proof_bytes: int + action_bytes: int + context_bytes: int + object_count: int + plan_leaves: int + plan_depth: int + work_units: int + + +@dataclass(frozen=True) +class Authorized: + kind: Literal["authorized"] + code: str + stage: VerificationStage + explanation: Explanation + metrics: VerificationMetrics + required_configuration: Optional[bytes] + local_configuration: bytes + result_cbor: bytes + + +@dataclass(frozen=True) +class Denied: + kind: Literal["denied"] + code: str + stage: VerificationStage + explanation: Explanation + metrics: VerificationMetrics + required_configuration: Optional[bytes] + local_configuration: bytes + result_cbor: bytes + + +@dataclass(frozen=True) +class Indeterminate: + kind: Literal["indeterminate"] + code: str + stage: VerificationStage + explanation: Explanation + metrics: VerificationMetrics + required_configuration: Optional[bytes] + local_configuration: bytes + result_cbor: bytes + + +VerificationResult = Union[Authorized, Denied, Indeterminate] +VerificationInput = Tuple[bytes, bytes, bytes] + + +def verify( + proof_cbor: bytes, + canonical_action_cbor: bytes, + trusted_context_cbor: bytes, +) -> VerificationResult: + native = verify_v1(proof_cbor, canonical_action_cbor, trusted_context_cbor) + return _project(native) + + +def verify_many(inputs: Iterable[VerificationInput]) -> Tuple[VerificationResult, ...]: + values = tuple(inputs) + for value in values: + if type(value) is not tuple or len(value) != 3: + raise TypeError("verification inputs must be three-byte tuples") + if any(type(part) is not bytes for part in value): + raise TypeError("verification inputs must be bytes") + return tuple(_project(value) for value in verify_many_v1(list(values))) + + +def _project(native: NativeVerificationResult) -> VerificationResult: + kind = native.kind + metrics = VerificationMetrics(*native.metrics) + explanation = _explain(kind, native.code) + required = native.required_configuration + local = native.local_configuration + encoded = native.result_cbor + if kind == "authorized": + if native.action is None: + raise RuntimeError("native verifier omitted authorized capability") + return Authorized( + "authorized", + native.code, + native.stage, + explanation, + metrics, + required, + local, + encoded, + ) + if kind == "denied": + return Denied( + "denied", + native.code, + native.stage, + explanation, + metrics, + required, + local, + encoded, + ) + return Indeterminate( + "indeterminate", + native.code, + native.stage, + explanation, + metrics, + required, + local, + encoded, + ) + + +def _explain(kind: VerdictKind, code: str) -> Explanation: + if kind == "authorized": + message = "the proof establishes exact authority for this action" + elif kind == "denied": + message = "the supplied proof does not authorize this exact action" + else: + message = "a required trustworthy fact or implementation is unavailable" + return Explanation(code=code, message=message, retryable=kind == "indeterminate") + + +__all__ = [ + "Authorized", + "Denied", + "Explanation", + "Indeterminate", + "VerificationInput", + "VerificationMetrics", + "VerificationResult", + "verify", + "verify_many", +] diff --git a/bindings/python/python/auths/workflow.py b/bindings/python/python/auths/workflow.py new file mode 100644 index 00000000..588c5bf2 --- /dev/null +++ b/bindings/python/python/auths/workflow.py @@ -0,0 +1,1815 @@ +"""Async agent attachment and authority delegation.""" + +from __future__ import annotations + +import asyncio +import time +from dataclasses import dataclass +from types import TracebackType +from typing import ( + Any, + Callable, + Literal, + Optional, + Protocol, + Sequence, + Set, + Tuple, + Type, + Union, + cast, + runtime_checkable, + TYPE_CHECKING, + overload, +) + +if TYPE_CHECKING: + from .profiles.mcp import ( + AuthorizationRequest, + McpAction, + McpAuthorizationResult, + McpPlan, + McpPlanAuthorizationResult, + ) + from .profiles.http import ( + HttpAction, + HttpAuthorizationRequest, + HttpAuthorizationResult, + HttpPlan, + HttpPlanAuthorizationResult, + ) + from .profile_kit import ( + ApplicationAction, + ApplicationPlan, + ApplicationPlanResult, + ApplicationRequest, + ApplicationResult, + ) + +from ._native import ( + ApprovalPolicyReference, + AuthorityDiff, + GrantAuthority, + NativeDelegationExpandedError, + Principal, + PrincipalDescriptor, + SignedObject, + TrustedContext, + approval_policy_reference, + bind_delegated_authority, + plan_child_fields, + prepare_signing_transaction, + validate_root_authority, + validate_trusted_authority, +) +from .errors import ( + AuthsError, + AuthsWorkflowError, + ProviderFailureKind, + ProviderOperationError, +) +from .observability import AuthsEvent, Telemetry + +SignerLifecycle = Literal["durable", "ephemeral"] +SigningObjectKind = Literal["grant", "action", "principal-status", "grant-status"] +ApprovalMode = Literal[ + "none", "grant-only", "risk-based", "every-action", "plan-once", "custom" +] +ApprovalDecision = Literal["approved", "rejected"] + +MAX_IDENTIFIER_BYTES = 128 +MAX_DISPLAY_FIELDS = 32 +MAX_DISPLAY_FIELD_BYTES = 4 * 1024 +MAX_DISPLAY_BYTES = 64 * 1024 +MAX_SIGNATURE_BYTES = 512 +MAX_EVIDENCE = 32 +MAX_EVIDENCE_BYTES = 64 * 1024 +MAX_COLLECTION = 256 +MAX_U64 = (1 << 64) - 1 + + +@dataclass(frozen=True) +class ReviewField: + label: str + value: str + + def __post_init__(self) -> None: + _bounded_display_text(self.label, "review label") + _bounded_display_text(self.value, "review value") + + +@dataclass(frozen=True) +class ControlEvidence: + evidence_type: str + media_type: str + bytes: bytes + + def __post_init__(self) -> None: + _bounded_identifier(self.evidence_type, "evidence type") + _bounded_identifier(self.media_type, "evidence media type") + object.__setattr__(self, "bytes", bytes(self.bytes)) + if not self.bytes or len(self.bytes) > MAX_EVIDENCE_BYTES: + raise ValueError("invalid control evidence bytes") + + +@dataclass(frozen=True) +class SigningRequest: + request_id: str + object_kind: SigningObjectKind + object_id: bytes + principal: PrincipalDescriptor + transaction_digest: bytes + signing_preimage: bytes + expires_at: int + display: Tuple[ReviewField, ...] + + +@dataclass(frozen=True) +class SigningResponse: + request_id: str + principal: PrincipalDescriptor + transaction_digest: bytes + signature: bytes + evidence: Tuple[ControlEvidence, ...] = () + + def __post_init__(self) -> None: + object.__setattr__(self, "transaction_digest", bytes(self.transaction_digest)) + object.__setattr__(self, "signature", bytes(self.signature)) + object.__setattr__(self, "evidence", tuple(self.evidence)) + + +@runtime_checkable +class Signer(Protocol): + kind: str + lifecycle: SignerLifecycle + + async def public_identity(self) -> PrincipalDescriptor: ... + + async def sign(self, request: SigningRequest) -> SigningResponse: ... + + async def aclose(self) -> None: ... + + +@dataclass(frozen=True) +class ApprovalRequest: + request_id: str + object_kind: SigningObjectKind + transaction_digest: bytes + policy: ApprovalPolicyReference + expires_at: int + display: Tuple[ReviewField, ...] + + +@dataclass(frozen=True) +class ApprovalResponse: + request_id: str + transaction_digest: bytes + policy: ApprovalPolicyReference + decision: ApprovalDecision + + def __post_init__(self) -> None: + object.__setattr__(self, "transaction_digest", bytes(self.transaction_digest)) + + +@runtime_checkable +class ApprovalProvider(Protocol): + async def approve(self, request: ApprovalRequest) -> ApprovalResponse: ... + + +@dataclass(frozen=True) +class ApprovalPolicy: + reference: ApprovalPolicyReference + mode: ApprovalMode + max_uses: int + expires_in_seconds: int + requirements: Tuple[str, ...] + + +@dataclass(frozen=True) +class ApprovalConfiguration: + policy: ApprovalPolicy + provider: ApprovalProvider + + +class Approval: + """Typed builders for committed approval policies.""" + + @staticmethod + def none( + policy_id: str = "approval.none", + *, + evaluator_version: str = "1", + expires_in_seconds: int = 300, + ) -> ApprovalConfiguration: + return _approval( + policy_id, + _NoApprovalProvider(), + "none", + evaluator_version, + 1, + expires_in_seconds, + (), + ) + + @staticmethod + def grant_only( + policy_id: str, + provider: ApprovalProvider, + *, + evaluator_version: str = "1", + max_uses: int = 1, + expires_in_seconds: int = 300, + requirements: Sequence[str] = (), + ) -> ApprovalConfiguration: + return _approval( + policy_id, + provider, + "grant-only", + evaluator_version, + max_uses, + expires_in_seconds, + requirements, + ) + + @staticmethod + def risk_based( + policy_id: str, + provider: ApprovalProvider, + *, + evaluator_version: str = "1", + max_uses: int = 1, + expires_in_seconds: int = 300, + requirements: Sequence[str] = (), + ) -> ApprovalConfiguration: + return _approval( + policy_id, + provider, + "risk-based", + evaluator_version, + max_uses, + expires_in_seconds, + requirements, + ) + + @staticmethod + def every_action( + policy_id: str, + provider: ApprovalProvider, + *, + evaluator_version: str = "1", + max_uses: int = 1, + expires_in_seconds: int = 300, + requirements: Sequence[str] = (), + ) -> ApprovalConfiguration: + return _approval( + policy_id, + provider, + "every-action", + evaluator_version, + max_uses, + expires_in_seconds, + requirements, + ) + + @staticmethod + def plan_once( + policy_id: str, + provider: ApprovalProvider, + *, + evaluator_version: str = "1", + max_uses: int, + expires_in_seconds: int = 300, + requirements: Sequence[str] = (), + ) -> ApprovalConfiguration: + return _approval( + policy_id, + provider, + "plan-once", + evaluator_version, + max_uses, + expires_in_seconds, + requirements, + ) + + @staticmethod + def custom( + policy_id: str, + provider: ApprovalProvider, + *, + evaluator_version: str, + max_uses: int, + expires_in_seconds: int, + requirements: Sequence[str], + ) -> ApprovalConfiguration: + return _approval( + policy_id, + provider, + "custom", + evaluator_version, + max_uses, + expires_in_seconds, + requirements, + ) + + +class _NoApprovalProvider: + async def approve(self, request: ApprovalRequest) -> ApprovalResponse: + return ApprovalResponse( + request.request_id, + request.transaction_digest, + request.policy, + "approved", + ) + + +@dataclass(frozen=True) +class Profile: + id: str + version: int + + def __post_init__(self) -> None: + _bounded_identifier(self.id, "profile") + _bounded_u16(self.version, "profile version") + + +@dataclass(frozen=True) +class Permission: + capability: str + resource: str + + def __post_init__(self) -> None: + _bounded_identifier(self.capability, "capability") + _bounded_identifier(self.resource, "resource") + + +@dataclass(frozen=True) +class Validity: + not_before: int + expires_at: int + + def __post_init__(self) -> None: + _bounded_u64(self.not_before, "not before") + _bounded_u64(self.expires_at, "expiry") + if self.not_before > self.expires_at: + raise ValueError("validity starts after it expires") + + +@dataclass(frozen=True) +class InheritAction: + pass + + +@dataclass(frozen=True) +class AnyBody: + pass + + +@dataclass(frozen=True) +class ExactBody: + digest: bytes + + def __post_init__(self) -> None: + object.__setattr__(self, "digest", _digest(self.digest, "body digest")) + + +@dataclass(frozen=True) +class AllowedBodies: + digests: Tuple[bytes, ...] + + def __post_init__(self) -> None: + values = tuple(_digest(value, "body digest") for value in self.digests) + if ( + not values + or len(values) > MAX_COLLECTION + or len(set(values)) != len(values) + ): + raise ValueError("invalid allowed body digests") + object.__setattr__(self, "digests", values) + + +DelegatedActionConstraint = Union[InheritAction, AnyBody, ExactBody, AllowedBodies] + + +@dataclass(frozen=True) +class InheritBudget: + pass + + +@dataclass(frozen=True) +class NoBudget: + pass + + +@dataclass(frozen=True) +class BudgetCeiling: + algebra: str + value: int + + def __post_init__(self) -> None: + _bounded_identifier(self.algebra, "budget algebra") + _bounded_u64(self.value, "budget value") + + +DelegatedBudget = Union[InheritBudget, NoBudget, BudgetCeiling] + + +@dataclass(frozen=True) +class InheritStatus: + pass + + +@dataclass(frozen=True) +class ExpiryOnly: + pass + + +@dataclass(frozen=True) +class SnapshotRequired: + method: str + max_age: int + + def __post_init__(self) -> None: + _bounded_identifier(self.method, "status method") + _bounded_u64(self.max_age, "status maximum age") + + +DelegatedStatus = Union[InheritStatus, ExpiryOnly, SnapshotRequired] + + +@dataclass(frozen=True) +class DelegatedAuthority: + permissions: Tuple[Permission, ...] + validity: Validity + audiences: Tuple[str, ...] + remaining_depth: int + action_constraint: DelegatedActionConstraint = InheritAction() + budget: DelegatedBudget = InheritBudget() + status: DelegatedStatus = InheritStatus() + assurance_floor: Optional[str] = None + + def __post_init__(self) -> None: + permissions = tuple(self.permissions) + audiences = tuple(self.audiences) + if ( + not permissions + or len(permissions) > MAX_COLLECTION + or not audiences + or len(audiences) > MAX_COLLECTION + ): + raise ValueError("delegated authority collections are invalid") + if not all(type(value) is Permission for value in permissions): + raise TypeError("permissions must contain Permission values") + for audience in audiences: + _bounded_identifier(audience, "audience") + _bounded_u16(self.remaining_depth, "remaining delegation depth") + if self.assurance_floor is not None: + _bounded_identifier(self.assurance_floor, "assurance floor") + if type(self.action_constraint) not in ( + InheritAction, + AnyBody, + ExactBody, + AllowedBodies, + ): + raise TypeError("unsupported action constraint") + if type(self.budget) not in (InheritBudget, NoBudget, BudgetCeiling): + raise TypeError("unsupported delegated budget") + if type(self.status) not in (InheritStatus, ExpiryOnly, SnapshotRequired): + raise TypeError("unsupported delegated status") + object.__setattr__(self, "permissions", permissions) + object.__setattr__(self, "audiences", audiences) + + +@dataclass(frozen=True) +class SignedGrantLoadRequest: + source_id: str + authority_id: str + subject: Principal + profile: Profile + + +@dataclass(frozen=True) +class SignedGrantMaterial: + signed_grant: SignedObject + evidence: Tuple[ControlEvidence, ...] = () + + def __post_init__(self) -> None: + if type(self.signed_grant) is not SignedObject: + raise TypeError("signed grant material requires a native signed object") + object.__setattr__(self, "evidence", _evidence(self.evidence)) + + +@runtime_checkable +class SignedGrantProvider(Protocol): + async def load_signed_grant( + self, request: SignedGrantLoadRequest + ) -> SignedGrantMaterial: ... + + +@dataclass(frozen=True) +class SignedGrantSource: + source_id: str + provider: SignedGrantProvider + + def __post_init__(self) -> None: + _bounded_identifier(self.source_id, "signed grant source") + if not callable(getattr(self.provider, "load_signed_grant", None)): + raise TypeError("signed grant provider is invalid") + + +SignedGrantInput = Union[SignedObject, SignedGrantMaterial, SignedGrantSource] + + +@dataclass(frozen=True) +class TrustedAuthority: + authority_id: str + root_principal: Principal + context: TrustedContext + required_approval: ApprovalPolicyReference + + def __post_init__(self) -> None: + _bounded_identifier(self.authority_id, "trusted authority") + if type(self.root_principal) is not Principal: + raise TypeError("trusted authority requires a native principal") + if type(self.context) is not TrustedContext: + raise TypeError("trusted authority requires a native trusted context") + if type(self.required_approval) is not ApprovalPolicyReference: + raise TypeError("trusted authority requires a native approval policy") + + +@dataclass(frozen=True) +class TrustedAuthoritySnapshot: + authority_id: str + root_principal: Principal + verifier_configuration: bytes + required_approval: ApprovalPolicyReference + + +@dataclass(frozen=True) +class AgentIdentity: + principal: PrincipalDescriptor + signer_kind: str + signer_lifecycle: SignerLifecycle + + +@dataclass(frozen=True) +class ActionConstraintSummary: + kind: Literal["any-body", "exact-body", "allowed-bodies"] + digest_count: int + + +@dataclass(frozen=True) +class BudgetSummary: + algebra: str + value: int + + +@dataclass(frozen=True) +class StatusSummary: + policy: Literal["expiry-only", "snapshot-required"] + method: Optional[str] + max_age: Optional[int] + + +@dataclass(frozen=True) +class SignatureSummary: + principal_method: str + verification_method: str + suite: str + + +@dataclass(frozen=True) +class AuthorityExplanation: + stage: Literal["attach"] + code: Literal[ + "root-authority-structurally-bound", + "delegated-authority-structurally-bound", + ] + verification: Literal["pending-authorization"] + message: str + + +@dataclass(frozen=True) +class EffectiveAuthoritySummary: + grant_id: bytes + issuer: Principal + subject: Principal + profile: Profile + permissions: Tuple[Permission, ...] + validity: Validity + audiences: Tuple[str, ...] + action_constraint: ActionConstraintSummary + budget: Optional[BudgetSummary] + remaining_depth: int + status: StatusSummary + assurance_floor: str + critical_extensions: Tuple[str, ...] + signature: SignatureSummary + explanation: AuthorityExplanation + + +@dataclass(frozen=True) +class DelegationReview: + diff: AuthorityDiff + warnings: Tuple[str, ...] + + +@dataclass(frozen=True) +class _SignedTransaction: + signed_object: SignedObject + transaction_digest: bytes + evidence: Tuple[ControlEvidence, ...] + + +class _SigningCoordinator: + def __init__(self, clock: Callable[[], int] = lambda: int(time.time())) -> None: + self._clock = clock + self._consumed = False + + async def execute( + self, + *, + unsigned: Any, + principal: PrincipalDescriptor, + signer: Signer, + approval: ApprovalConfiguration, + required_approval: ApprovalPolicyReference, + expires_at: int, + display: Sequence[ReviewField], + ) -> _SignedTransaction: + if self._consumed: + raise AuthsWorkflowError( + "transaction-consumed", + "signing transaction has already reached a terminal state", + ) + self._consumed = True + _validate_signer(signer) + _validate_approval(approval) + _bounded_u64(expires_at, "transaction expiry") + fields = _display(display) + if not required_approval.matches(approval.policy.reference): + raise AuthsWorkflowError( + "approval-policy-mismatch", + "executed approval policy does not match the trusted authority", + ) + if self._clock() > expires_at: + raise AuthsWorkflowError( + "transaction-expired", "signing transaction expired before provider use" + ) + try: + transaction = prepare_signing_transaction( + unsigned, + principal, + approval.policy.reference, + expires_at, + ) + except (TypeError, ValueError): + raise AuthsWorkflowError( + "invalid-provider", "native authoring rejected the signing transaction" + ) from None + try: + approval_response = await _call_approval( + approval.provider, + ApprovalRequest( + request_id=transaction.request_id, + object_kind=cast(SigningObjectKind, transaction.object_kind), + transaction_digest=transaction.transaction_digest, + policy=transaction.policy, + expires_at=transaction.expires_at, + display=fields, + ), + ) + if type(approval_response) is not ApprovalResponse: + raise AuthsWorkflowError( + "approval-response-mismatch", + "approval response is not bound to the exact transaction", + ) + try: + approved = transaction.accept_approval( + approval_response.request_id, + approval_response.transaction_digest, + approval_response.policy, + approval_response.decision, + self._clock(), + ) + except RuntimeError as error: + raise _transaction_runtime_error(error) from None + except (TypeError, ValueError): + raise AuthsWorkflowError( + "approval-response-mismatch", + "approval response is not bound to the exact transaction", + ) from None + if not approved: + raise AuthsWorkflowError( + "approval-rejected", + "approval provider rejected the signing transaction", + ) + signing_response = await _call_signer( + signer, + SigningRequest( + request_id=transaction.request_id, + object_kind=cast(SigningObjectKind, transaction.object_kind), + object_id=transaction.object_id, + principal=transaction.principal, + transaction_digest=transaction.transaction_digest, + signing_preimage=transaction.signing_preimage, + expires_at=transaction.expires_at, + display=fields, + ), + ) + if type(signing_response) is not SigningResponse: + raise AuthsWorkflowError( + "signer-response-mismatch", + "signer response is not bound to the exact transaction", + ) + signature = bytes(signing_response.signature) + if not signature or len(signature) > MAX_SIGNATURE_BYTES: + raise AuthsWorkflowError( + "signer-response-mismatch", + "signer returned an invalid signature length", + ) + evidence = _evidence(signing_response.evidence) + transaction_digest = transaction.transaction_digest + try: + signed = transaction.complete_response( + signing_response.request_id, + signing_response.principal, + signing_response.transaction_digest, + signature, + self._clock(), + ) + except RuntimeError as error: + raise _transaction_runtime_error(error) from None + except (TypeError, ValueError): + raise AuthsWorkflowError( + "signer-response-mismatch", + "signer response is not bound to the exact transaction", + ) from None + return _SignedTransaction(signed, transaction_digest, evidence) + finally: + transaction.discard() + + +class AuthsClient: + """Owns one root signer and its attached agent graph.""" + + def __init__( + self, + *, + signer: Signer, + trusted_authority: TrustedAuthority, + telemetry: Optional[Telemetry] = None, + ) -> None: + _validate_signer(signer) + if type(trusted_authority) is not TrustedAuthority: + raise TypeError("trusted_authority must be a TrustedAuthority") + self._signer = signer + self._configured_authority = trusted_authority + if telemetry is not None and not callable(getattr(telemetry, "emit", None)): + raise TypeError("telemetry must implement emit") + self._telemetry = telemetry + self._identity: Optional[AgentIdentity] = None + self._agents: Set[AttachedAgent] = set() + self._open = False + self._closed = False + + @property + def identity(self) -> AgentIdentity: + self._assert_open() + if self._identity is None: + raise AuthsWorkflowError("disposed", "Auths client is not open") + return self._identity + + @property + def trusted_authority(self) -> TrustedAuthoritySnapshot: + self._assert_open() + authority = self._configured_authority + return TrustedAuthoritySnapshot( + authority_id=authority.authority_id, + root_principal=authority.root_principal, + verifier_configuration=bytes(authority.context.configuration), + required_approval=authority.required_approval, + ) + + @property + def closed(self) -> bool: + return self._closed + + async def open(self) -> AuthsClient: + if self._closed: + raise AuthsWorkflowError("disposed", "Auths client is disposed") + if self._open: + return self + try: + try: + validate_trusted_authority( + self._configured_authority.context, + self._configured_authority.root_principal, + ) + except (TypeError, ValueError): + raise AuthsWorkflowError( + "invalid-trusted-authority", + "trusted authority does not bind the configured root and verifier", + ) from None + descriptor = await _call_public_identity(self._signer, "signer") + self._identity = AgentIdentity( + principal=descriptor, + signer_kind=_bounded_identifier(self._signer.kind, "signer kind"), + signer_lifecycle=_signer_lifecycle(self._signer.lifecycle), + ) + self._open = True + self._emit("auths.client.open", "client", "open", "succeeded") + return self + except asyncio.CancelledError: + await self._close_after_failed_open() + raise + except Exception: + await self._close_after_failed_open() + raise + + async def attach_agent( + self, + *, + name: str, + profile: Profile, + authority: SignedGrantInput, + approval: ApprovalConfiguration, + ) -> AttachedAgent: + self._assert_open() + name = _agent_name(name) + if not isinstance(profile, Profile): + raise TypeError("profile must be a Profile") + _validate_approval(approval) + if not self._configured_authority.required_approval.matches( + approval.policy.reference + ): + raise AuthsWorkflowError( + "approval-policy-mismatch", + "attach approval policy does not match the trusted authority", + ) + material = await _load_signed_grant( + authority, + SignedGrantLoadRequest( + source_id=( + authority.source_id + if type(authority) is SignedGrantSource + else "direct" + ), + authority_id=self._configured_authority.authority_id, + subject=self.identity.principal.principal, + profile=profile, + ), + ) + if material.signed_grant.kind != "grant": + raise AuthsWorkflowError( + "invalid-authority", "authority input is not a signed grant" + ) + try: + bound = validate_root_authority( + material.signed_grant, + self._configured_authority.root_principal, + self.identity.principal, + profile.id, + profile.version, + ) + except (TypeError, ValueError): + raise AuthsWorkflowError( + "authority-mismatch", + "signed grant does not bind the trusted root, agent, and profile", + ) from None + agent = AttachedAgent._create( + client=self, + name=name, + identity=self.identity, + profile=profile, + authority=bound, + approval=approval, + signer=self._signer, + owns_signer=False, + grant_chain=(material,), + delegation=None, + ) + self._agents.add(agent) + self._emit( + "auths.agent.attach", + "attach", + "authority", + "succeeded", + (("profile", profile.id), ("profile_version", profile.version)), + ) + return agent + + async def aclose(self) -> None: + if self._closed: + return + self._closed = True + self._open = False + failed = False + for agent in tuple(self._agents): + if not await agent._close(suppress_errors=True): + failed = True + self._agents.clear() + if not await _close_signer(self._signer): + failed = True + self._identity = None + if failed: + raise AuthsWorkflowError( + "cleanup-failed", "one or more signer providers failed during cleanup" + ) + self._emit("auths.client.close", "client", "cleanup", "succeeded") + + def _emit( + self, + name: str, + operation: str, + stage: str, + outcome: str, + attributes: Tuple[Tuple[str, Union[str, int, bool]], ...] = (), + ) -> None: + if self._telemetry is None: + return + try: + self._telemetry.emit( + AuthsEvent( + name, + operation, + stage, + outcome, + int(time.time()), + attributes, + ) + ) + except Exception: + return + + async def _close_after_failed_open(self) -> None: + self._closed = True + self._open = False + self._identity = None + await _close_signer(self._signer) + + def _assert_open(self) -> None: + if self._closed or not self._open: + raise AuthsWorkflowError("disposed", "Auths client is not open") + + async def __aenter__(self) -> AuthsClient: + return await self.open() + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + try: + await self.aclose() + except AuthsWorkflowError: + if exc is None: + raise + + +class AttachedAgent: + """One structurally bound authority and its configured signer.""" + + _TOKEN = object() + + def __init__( + self, + token: object, + *, + client: AuthsClient, + name: str, + identity: AgentIdentity, + profile: Profile, + authority: GrantAuthority, + approval: ApprovalConfiguration, + signer: Signer, + owns_signer: bool, + grant_chain: Tuple[SignedGrantMaterial, ...], + delegation: Optional[DelegationReview], + ) -> None: + if token is not self._TOKEN: + raise TypeError("sealed Auths attached agent") + self._client = client + self._name = name + self._identity = identity + self._profile = profile + self._native_authority = authority + self._authority = _authority_summary(authority) + self._approval = approval + self._signer = signer + self._owns_signer = owns_signer + self._grant_chain: Tuple[SignedGrantMaterial, ...] = grant_chain + self._delegation = delegation + self._closed = False + + @classmethod + def _create( + cls, + *, + client: AuthsClient, + name: str, + identity: AgentIdentity, + profile: Profile, + authority: GrantAuthority, + approval: ApprovalConfiguration, + signer: Signer, + owns_signer: bool, + grant_chain: Tuple[SignedGrantMaterial, ...], + delegation: Optional[DelegationReview], + ) -> AttachedAgent: + return cls( + cls._TOKEN, + client=client, + name=name, + identity=identity, + profile=profile, + authority=authority, + approval=approval, + signer=signer, + owns_signer=owns_signer, + grant_chain=grant_chain, + delegation=delegation, + ) + + @property + def name(self) -> str: + self._assert_active() + return self._name + + @property + def identity(self) -> AgentIdentity: + self._assert_active() + return self._identity + + @property + def profile(self) -> Profile: + self._assert_active() + return self._profile + + @property + def authority(self) -> EffectiveAuthoritySummary: + self._assert_active() + return self._authority + + @property + def delegation(self) -> Optional[DelegationReview]: + self._assert_active() + return self._delegation + + @property + def closed(self) -> bool: + return self._closed + + async def delegate( + self, + *, + name: str, + authority: DelegatedAuthority, + signer: Signer, + ) -> AttachedAgent: + self._assert_active() + name = _agent_name(name) + if type(authority) is not DelegatedAuthority: + raise TypeError("authority must be a DelegatedAuthority") + _validate_signer(signer) + child_identity: Optional[AgentIdentity] = None + transferred = False + try: + descriptor = await _call_public_identity(signer, "child signer") + child_identity = AgentIdentity( + principal=descriptor, + signer_kind=_bounded_identifier(signer.kind, "signer kind"), + signer_lifecycle=_signer_lifecycle(signer.lifecycle), + ) + action_mode, action_digests = _action_fields(authority.action_constraint) + budget_mode, budget = _budget_fields(authority.budget) + status_mode, status = _status_fields(authority.status) + try: + plan = plan_child_fields( + self._native_authority, + descriptor, + [ + (permission.capability, permission.resource) + for permission in authority.permissions + ], + authority.validity.not_before, + authority.validity.expires_at, + list(authority.audiences), + action_mode, + list(action_digests), + budget_mode, + budget, + authority.remaining_depth, + status_mode, + status, + authority.assurance_floor, + ) + except NativeDelegationExpandedError as error: + dimension = str(error) + raise AuthsWorkflowError( + "delegation-expanded", + "native authoring rejected widened child authority: " + dimension, + ) from None + except (TypeError, ValueError): + raise AuthsWorkflowError( + "invalid-delegation", + "native authoring rejected invalid child authority", + ) from None + review = DelegationReview(plan.diff, tuple(plan.warnings)) + expires_at = _transaction_expiry(self._approval.policy.expires_in_seconds) + signed = await _SigningCoordinator().execute( + unsigned=plan.unsigned, + principal=self.identity.principal, + signer=self._signer, + approval=self._approval, + required_approval=self._client._configured_authority.required_approval, + expires_at=expires_at, + display=_delegation_display(name, self, child_identity, review), + ) + try: + bound = bind_delegated_authority( + signed.signed_object, + self._native_authority, + child_identity.principal, + self.identity.principal, + self.profile.id, + self.profile.version, + ) + except (TypeError, ValueError): + raise AuthsWorkflowError( + "authority-mismatch", + "signed child authority does not match the native plan", + ) from None + material = SignedGrantMaterial(signed.signed_object, signed.evidence) + child = AttachedAgent._create( + client=self._client, + name=name, + identity=child_identity, + profile=self._profile, + authority=bound, + approval=self._approval, + signer=signer, + owns_signer=True, + grant_chain=self._grant_chain + (material,), + delegation=review, + ) + self._client._agents.add(child) + transferred = True + return child + except asyncio.CancelledError: + raise + finally: + if not transferred: + await _close_signer(signer) + + @overload + async def authorize( + self, + action: McpAction, + *, + request: Optional[AuthorizationRequest] = None, + ) -> McpAuthorizationResult: + ... + + @overload + async def authorize( + self, + action: HttpAction, + *, + request: Optional[HttpAuthorizationRequest] = None, + ) -> HttpAuthorizationResult: + ... + + @overload + async def authorize( + self, + action: ApplicationAction[Any], + *, + request: Optional[ApplicationRequest] = None, + ) -> ApplicationResult[Any]: + ... + + async def authorize(self, action: object, *, request: Optional[object] = None) -> object: + from .profiles.http import HttpAction, _authorize_http + from .profiles.mcp import McpAction, _authorize_mcp + from .profile_kit import ApplicationAction, _authorize_application + + if type(action) is McpAction: + return await _authorize_mcp(self, action, cast(Any, request)) + if type(action) is HttpAction: + return await _authorize_http(self, action, cast(Any, request)) + if type(action) is ApplicationAction: + return await _authorize_application( + self, cast(Any, action), cast(Any, request) + ) + raise TypeError("action must belong to a maintained Auths profile") + + @overload + async def authorize_plan( + self, + plan: McpPlan, + *, + approval_provider: Optional[ApprovalProvider] = None, + requests: Optional[Sequence[AuthorizationRequest]] = None, + ) -> McpPlanAuthorizationResult: + ... + + @overload + async def authorize_plan( + self, + plan: HttpPlan, + *, + approval_provider: Optional[ApprovalProvider] = None, + requests: Optional[Sequence[HttpAuthorizationRequest]] = None, + ) -> HttpPlanAuthorizationResult: + ... + + @overload + async def authorize_plan( + self, + plan: ApplicationPlan[Any], + *, + approval_provider: Optional[ApprovalProvider] = None, + requests: Optional[Sequence[ApplicationRequest]] = None, + ) -> ApplicationPlanResult[Any]: + ... + + async def authorize_plan( + self, + plan: object, + *, + approval_provider: Optional[ApprovalProvider] = None, + requests: Optional[Sequence[object]] = None, + ) -> object: + from .profiles.http import HttpPlan, _authorize_http_plan + from .profiles.mcp import McpPlan, _authorize_mcp_plan + from .profile_kit import ApplicationPlan, _authorize_application_plan + + if type(plan) is McpPlan: + return await _authorize_mcp_plan( + self, + plan, + approval_provider, + cast(Any, requests), + ) + if type(plan) is HttpPlan: + return await _authorize_http_plan( + self, + plan, + approval_provider, + cast(Any, requests), + ) + if type(plan) is ApplicationPlan: + return await _authorize_application_plan( + self, + cast(Any, plan), + approval_provider, + cast(Any, requests), + ) + raise TypeError("plan must belong to a maintained Auths profile") + + async def aclose(self) -> None: + if not await self._close(suppress_errors=False): + raise AuthsWorkflowError( + "cleanup-failed", "child signer provider cleanup failed" + ) + + async def _close(self, *, suppress_errors: bool) -> bool: + if self._closed: + return True + self._closed = True + self._client._agents.discard(self) + successful = True + if self._owns_signer: + successful = await _close_signer(self._signer) + self._grant_chain = () + if not successful and not suppress_errors: + return False + return successful + + def _assert_active(self) -> None: + if self._closed: + raise AuthsWorkflowError("disposed", "attached agent is disposed") + self._client._assert_open() + + async def __aenter__(self) -> AttachedAgent: + self._assert_active() + return self + + async def __aexit__( + self, + exc_type: Optional[Type[BaseException]], + exc: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + try: + await self.aclose() + except AuthsWorkflowError: + if exc is None: + raise + + +def _approval( + policy_id: str, + provider: ApprovalProvider, + mode: ApprovalMode, + evaluator_version: str, + max_uses: int, + expires_in_seconds: int, + requirements: Sequence[str], +) -> ApprovalConfiguration: + if not callable(getattr(provider, "approve", None)): + raise TypeError("approval provider is invalid") + reference, values = _approval_commitment( + policy_id, + evaluator_version, + mode, + max_uses, + expires_in_seconds, + requirements, + ) + return ApprovalConfiguration( + ApprovalPolicy(reference, mode, max_uses, expires_in_seconds, values), + provider, + ) + + +def _approval_commitment( + policy_id: str, + evaluator_version: str, + mode: ApprovalMode, + max_uses: int, + expires_in_seconds: int, + requirements: Sequence[str], +) -> Tuple[ApprovalPolicyReference, Tuple[str, ...]]: + _bounded_u32(max_uses, "approval maximum uses") + _bounded_u32(expires_in_seconds, "approval expiry") + if max_uses == 0 or expires_in_seconds == 0: + raise ValueError("approval bounds must be positive") + values = tuple(requirements) + if len(values) > MAX_COLLECTION or len(set(values)) != len(values): + raise ValueError("approval requirements are invalid") + for requirement in values: + _bounded_identifier(requirement, "approval requirement") + reference = approval_policy_reference( + policy_id, + evaluator_version, + mode, + max_uses, + expires_in_seconds, + list(values), + ) + return reference, values + + +async def _call_public_identity(signer: Signer, label: str) -> PrincipalDescriptor: + try: + descriptor = await signer.public_identity() + except asyncio.CancelledError: + raise + except ProviderOperationError as error: + raise _provider_failure("signer", error.kind) from None + except TimeoutError: + raise _provider_failure("signer", "timeout") from None + except Exception: + raise AuthsWorkflowError( + "invalid-principal", label + " returned an invalid principal descriptor" + ) from None + if type(descriptor) is not PrincipalDescriptor: + raise AuthsWorkflowError( + "invalid-principal", label + " returned an invalid principal descriptor" + ) + return descriptor + + +async def _call_approval( + provider: ApprovalProvider, request: ApprovalRequest +) -> ApprovalResponse: + try: + return await provider.approve(request) + except asyncio.CancelledError: + raise + except ProviderOperationError as error: + raise _provider_failure("approval", error.kind) from None + except TimeoutError: + raise _provider_failure("approval", "timeout") from None + except Exception: + raise AuthsWorkflowError( + "approval-failed", "approval provider failed" + ) from None + + +async def _call_signer(signer: Signer, request: SigningRequest) -> SigningResponse: + try: + return await signer.sign(request) + except asyncio.CancelledError: + raise + except ProviderOperationError as error: + raise _provider_failure("signer", error.kind) from None + except TimeoutError: + raise _provider_failure("signer", "timeout") from None + except Exception: + raise AuthsWorkflowError("signer-failed", "signer provider failed") from None + + +def _provider_failure( + operation: Literal["approval", "signer"], kind: ProviderFailureKind +) -> AuthsWorkflowError: + suffix = { + "unavailable": "failed", + "rejected": "rejected", + "cancelled": "cancelled", + "timeout": "timeout", + "unsupported": "unsupported", + }[kind] + return AuthsWorkflowError( + operation + "-" + suffix, + operation + " provider failed", + ) + + +def _transaction_runtime_error(error: RuntimeError) -> AuthsWorkflowError: + if "expired" in str(error): + return AuthsWorkflowError( + "transaction-expired", "signing transaction expired during provider use" + ) + return AuthsWorkflowError( + "transaction-consumed", + "signing transaction has already reached a terminal state", + ) + + +async def _load_signed_grant( + value: SignedGrantInput, request: SignedGrantLoadRequest +) -> SignedGrantMaterial: + if type(value) is SignedObject: + return SignedGrantMaterial(value) + if type(value) is SignedGrantMaterial: + return value + if type(value) is not SignedGrantSource: + raise TypeError( + "authority must be native signed grant material or a typed source" + ) + try: + material = await value.provider.load_signed_grant(request) + except asyncio.CancelledError: + raise + except ProviderOperationError as error: + raise AuthsWorkflowError( + "authority-source-" + error.kind, + "signed grant provider failed", + ) from None + except TimeoutError: + raise AuthsWorkflowError( + "authority-source-timeout", "signed grant provider timed out" + ) from None + except Exception: + raise AuthsWorkflowError( + "authority-source-failed", "signed grant provider failed" + ) from None + if type(material) is not SignedGrantMaterial: + raise AuthsWorkflowError( + "invalid-authority", "signed grant provider returned an invalid value" + ) + return material + + +async def _close_signer(signer: Signer) -> bool: + try: + operation = signer.aclose() + await asyncio.shield(operation) + return True + except asyncio.CancelledError: + raise + except Exception: + return False + + +def _validate_signer(signer: Signer) -> None: + if ( + not callable(getattr(signer, "public_identity", None)) + or not callable(getattr(signer, "sign", None)) + or not callable(getattr(signer, "aclose", None)) + ): + raise TypeError("signer does not implement the required protocol") + _bounded_identifier(getattr(signer, "kind", None), "signer kind") + _signer_lifecycle(getattr(signer, "lifecycle", None)) + + +def _validate_approval(approval: ApprovalConfiguration) -> None: + if type(approval) is not ApprovalConfiguration: + raise TypeError("approval must be an ApprovalConfiguration") + if type(approval.policy) is not ApprovalPolicy: + raise TypeError("approval policy is invalid") + if type(approval.policy.reference) is not ApprovalPolicyReference: + raise TypeError("approval policy reference is invalid") + if approval.policy.mode not in ( + "grant-only", + "risk-based", + "every-action", + "plan-once", + "custom", + ): + raise TypeError("approval mode is invalid") + committed, _ = _approval_commitment( + approval.policy.reference.policy_id, + approval.policy.reference.evaluator_version, + approval.policy.mode, + approval.policy.max_uses, + approval.policy.expires_in_seconds, + approval.policy.requirements, + ) + if not approval.policy.reference.matches(committed): + raise ValueError("approval policy fields do not match their native commitment") + if not callable(getattr(approval.provider, "approve", None)): + raise TypeError("approval provider is invalid") + + +def _signer_lifecycle(value: object) -> SignerLifecycle: + if value == "durable": + return "durable" + if value == "ephemeral": + return "ephemeral" + raise ValueError("invalid signer lifecycle") + + +def _action_fields( + value: DelegatedActionConstraint, +) -> Tuple[str, Tuple[bytes, ...]]: + if type(value) is InheritAction: + return "inherit", () + if type(value) is AnyBody: + return "any-body", () + if type(value) is ExactBody: + return "exact-body", (value.digest,) + if type(value) is AllowedBodies: + return "allowed-bodies", value.digests + raise TypeError("unsupported action constraint") + + +def _budget_fields(value: DelegatedBudget) -> Tuple[str, Optional[Tuple[str, int]]]: + if type(value) is InheritBudget: + return "inherit", None + if type(value) is NoBudget: + return "none", None + if type(value) is BudgetCeiling: + return "ceiling", (value.algebra, value.value) + raise TypeError("unsupported delegated budget") + + +def _status_fields(value: DelegatedStatus) -> Tuple[str, Optional[Tuple[str, int]]]: + if type(value) is InheritStatus: + return "inherit", None + if type(value) is ExpiryOnly: + return "expiry-only", None + if type(value) is SnapshotRequired: + return "snapshot-required", (value.method, value.max_age) + raise TypeError("unsupported delegated status") + + +def _authority_summary(value: GrantAuthority) -> EffectiveAuthoritySummary: + profile_id, profile_version = value.profile + action_kind, digest_count = value.action_constraint + status_policy, status_method, status_max_age = value.status + signature_method, verification_method, suite = value.signature + if action_kind not in ("any-body", "exact-body", "allowed-bodies"): + raise AuthsWorkflowError( + "invalid-authority", "native authority returned an invalid action summary" + ) + if status_policy not in ("expiry-only", "snapshot-required"): + raise AuthsWorkflowError( + "invalid-authority", "native authority returned an invalid status summary" + ) + budget = value.budget + binding = value.binding + root = binding == "root" + return EffectiveAuthoritySummary( + grant_id=bytes(value.grant_id), + issuer=value.issuer, + subject=value.subject, + profile=Profile(profile_id, profile_version), + permissions=tuple(Permission(*permission) for permission in value.permissions), + validity=Validity(*value.validity), + audiences=tuple(value.audiences), + action_constraint=ActionConstraintSummary( + cast( + Literal["any-body", "exact-body", "allowed-bodies"], + action_kind, + ), + digest_count, + ), + budget=None if budget is None else BudgetSummary(*budget), + remaining_depth=value.remaining_depth, + status=StatusSummary( + cast(Literal["expiry-only", "snapshot-required"], status_policy), + status_method, + status_max_age, + ), + assurance_floor=value.assurance_floor, + critical_extensions=tuple(value.critical_extensions), + signature=SignatureSummary( + signature_method, + verification_method, + suite, + ), + explanation=AuthorityExplanation( + stage="attach", + code=( + "root-authority-structurally-bound" + if root + else "delegated-authority-structurally-bound" + ), + verification="pending-authorization", + message=( + "Canonical root authority is bound; cryptographic and live checks remain pending authorization." + if root + else "Canonical delegated authority is bound; cryptographic and live checks remain pending authorization." + ), + ), + ) + + +def _delegation_display( + name: str, + parent: AttachedAgent, + child: AgentIdentity, + review: DelegationReview, +) -> Tuple[ReviewField, ...]: + return _display( + ( + ReviewField("Agent", name), + ReviewField("Issuer", parent.identity.principal.principal.value), + ReviewField("Subject", child.principal.principal.value), + ReviewField( + "Profile", parent.profile.id + "/" + str(parent.profile.version) + ), + ReviewField( + "Authority", + str(review.diff.removed_permissions) + + " permissions removed; " + + str(len(review.warnings)) + + " warnings", + ), + ) + ) + + +def _display(values: Sequence[ReviewField]) -> Tuple[ReviewField, ...]: + fields = tuple(values) + if len(fields) > MAX_DISPLAY_FIELDS or not all( + type(value) is ReviewField for value in fields + ): + raise ValueError("invalid review display") + if ( + sum(len(value.label.encode()) + len(value.value.encode()) for value in fields) + > MAX_DISPLAY_BYTES + ): + raise ValueError("review display exceeds its byte limit") + return fields + + +def _evidence(values: Sequence[ControlEvidence]) -> Tuple[ControlEvidence, ...]: + evidence = tuple(values) + if len(evidence) > MAX_EVIDENCE or not all( + type(value) is ControlEvidence for value in evidence + ): + raise ValueError("invalid control evidence") + if sum(len(value.bytes) for value in evidence) > MAX_EVIDENCE_BYTES: + raise ValueError("control evidence exceeds its aggregate byte limit") + return evidence + + +def _transaction_expiry(expires_in_seconds: int) -> int: + now = int(time.time()) + expiry = now + expires_in_seconds + _bounded_u64(expiry, "transaction expiry") + return expiry + + +def _agent_name(value: str) -> str: + return _bounded_identifier(value, "agent name") + + +def _bounded_identifier(value: object, label: str) -> str: + if ( + not isinstance(value, str) + or not value + or len(value.encode()) > MAX_IDENTIFIER_BYTES + or any(character.isspace() and character not in (" ",) for character in value) + or any(ord(character) < 32 for character in value) + ): + raise ValueError("invalid " + label) + return value + + +def _bounded_display_text(value: object, label: str) -> str: + if ( + not isinstance(value, str) + or not value + or len(value.encode()) > MAX_DISPLAY_FIELD_BYTES + or any( + ord(character) < 32 and character not in ("\n", "\t") for character in value + ) + ): + raise ValueError("invalid " + label) + return value + + +def _digest(value: bytes, label: str) -> bytes: + result = bytes(value) + if len(result) != 32: + raise ValueError(label + " must contain 32 bytes") + return result + + +def _bounded_u16(value: object, label: str) -> int: + if ( + isinstance(value, bool) + or not isinstance(value, int) + or value < 0 + or value > 0xFFFF + ): + raise ValueError("invalid " + label) + return value + + +def _bounded_u32(value: object, label: str) -> int: + if ( + isinstance(value, bool) + or not isinstance(value, int) + or value < 0 + or value > 0xFFFFFFFF + ): + raise ValueError("invalid " + label) + return value + + +def _bounded_u64(value: object, label: str) -> int: + if ( + isinstance(value, bool) + or not isinstance(value, int) + or value < 0 + or value > MAX_U64 + ): + raise ValueError("invalid " + label) + return value + + +__all__ = [ + "ActionConstraintSummary", + "AgentIdentity", + "AllowedBodies", + "AnyBody", + "Approval", + "ApprovalConfiguration", + "ApprovalDecision", + "ApprovalMode", + "ApprovalPolicy", + "ApprovalPolicyReference", + "ApprovalProvider", + "ApprovalRequest", + "ApprovalResponse", + "AttachedAgent", + "AuthorityExplanation", + "AuthsClient", + "AuthsError", + "AuthsWorkflowError", + "BudgetCeiling", + "BudgetSummary", + "ControlEvidence", + "DelegatedActionConstraint", + "DelegatedAuthority", + "DelegatedBudget", + "DelegatedStatus", + "DelegationReview", + "EffectiveAuthoritySummary", + "ExactBody", + "ExpiryOnly", + "InheritAction", + "InheritBudget", + "InheritStatus", + "NoBudget", + "Permission", + "Principal", + "PrincipalDescriptor", + "Profile", + "ProviderFailureKind", + "ProviderOperationError", + "ReviewField", + "SignatureSummary", + "SignedGrantInput", + "SignedGrantLoadRequest", + "SignedGrantMaterial", + "SignedGrantProvider", + "SignedGrantSource", + "Signer", + "SignerLifecycle", + "SigningObjectKind", + "SigningRequest", + "SigningResponse", + "SnapshotRequired", + "StatusSummary", + "TrustedAuthority", + "TrustedAuthoritySnapshot", + "Validity", +] diff --git a/bindings/python/sdk-capability.json b/bindings/python/sdk-capability.json new file mode 100644 index 00000000..a69b68f7 --- /dev/null +++ b/bindings/python/sdk-capability.json @@ -0,0 +1,69 @@ +{ + "schema": "auths.sdk-capability/2", + "package": "auths", + "language": "python", + "implementationTier": "full-workflow-sdk", + "evidenceStatus": "repository-local-complete", + "promotedTier": "verifier-binding", + "targetTier": "full-workflow-sdk", + "governingSpec": "AP-SPEC-035", + "implementationStatus": "elite-repository-implementation-complete", + "eliteScorecard": { + "timeToValue": "installed identity and full-workflow quickstarts", + "pythonFit": "async protocols, immutable values, context managers and exhaustive unions", + "nativeSafety": "non-constructible profile-bound one-use commands", + "semanticParity": "Rust-owned canonicalization, authoring, verification, plans and lifecycle", + "surfaceBreadth": "identity, trust, authority, lifecycle, profiles, runtime, receipts and inspection", + "productionIntegration": "versioned replaceable ports with deterministic testkit checks", + "operations": "replay, budget, cancellation, cleanup and outcome-unknown states", + "performance": "bounded order-preserving GIL-releasing native batch verification", + "packaging": "exact abi3 wheel, API, content and runtime-contract qualification", + "documentation": "current topology README and executable installed-wheel journeys" + }, + "publicationStatus": "blocked", + "promotionStatus": "blocked", + "claims": { + "supported": [ + "bounded three-input local verification", + "three-valued native result projection", + "non-constructible native verified action", + "repository-local native principal, grant, status, plan, profile, trust and signing operations", + "provider-neutral async signer and approval protocols", + "native-bound root authority attachment", + "native-attenuated child delegation with semantic review", + "single-use exact signing transactions and deterministic provider cleanup", + "native-assembled proof and trusted-context verification for MCP actions", + "sealed single-use MCP commands and ordered plan commands", + "exact ordered plan-once approval with no partial command exposure", + "complete immutable decision inspection and inert caller-supplied diagnostics", + "standalone authority-independent identity decoding, resolution, validation and authentication", + "raw-key Ed25519 and resolver-backed identity reference paths", + "credential-shape-agnostic identity descriptors with multiple purpose and suite relationships", + "composite, threshold and hybrid verification-material representation without adapter ownership", + "explicit identity-to-authority input preserving method, suite, purpose, provenance and assurance", + "closed HTTP actions, ordered plans, one-use commands, gateways and receipts", + "idempotency-required MCP, HTTP and application gateways with command-bound receipts", + "Rust-owned all-of, any-of and threshold proof-plan composition", + "committed no-approval and bounded threshold approval-provider composition", + "typed trust compilation, evidence limits, lifecycle authoring and status snapshots", + "native-sealed application profile actions, commands and ordered plan commands", + "Rust-owned lifecycle transition, replay and capacity decisions behind replaceable Python ports", + "bounded order-preserving native batch verification with GIL release", + "stable structured redacted errors, telemetry events, decision timelines and support bundles", + "development adapters and executable provider checks under auths.testkit", + "direct verify, inspection and diagnostics modules with no advanced or native public facade", + "shared Rust, TypeScript and Python customer-journey projections", + "strict mypy and Pyright consumer contracts", + "installed abi3 wheel qualification on Linux, macOS and Windows for every CPython 3.9 through 3.14 minor" + ], + "excluded": [ + "promoted full workflow SDK release claim", + "independent review", + "production readiness", + "backward compatibility or migration support", + "production custody or approval adapters", + "atomicity of remote provider effects", + "package publication authorization" + ] + } +} diff --git a/bindings/python/sdk-runtime-contract.json b/bindings/python/sdk-runtime-contract.json new file mode 100644 index 00000000..f82feb2e --- /dev/null +++ b/bindings/python/sdk-runtime-contract.json @@ -0,0 +1,48 @@ +{ + "schema": "auths.python-runtime-contract/1", + "package": { + "name": "auths", + "version": "1.0.0rc1", + "nativeAbi": 2, + "wheelAbi": "abi3-py39" + }, + "runtime": { + "implementation": "CPython", + "versions": ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"], + "operatingSystems": ["linux", "macos", "windows"], + "freeThreaded": false + }, + "distribution": { + "wheels": "published", + "sourceDistribution": "not-published", + "localCompilerRequired": false + }, + "semanticSubjects": { + "identity": "auths-identity/v1", + "authority": "auths-proof-protocol/v1", + "mcpProfile": "auths.mcp/1", + "httpProfile": "auths.http/1", + "lifecycle": "auths.product.reservation-execution-contract/1" + }, + "modules": [ + "auths", + "auths.identity", + "auths.trust", + "auths.authority", + "auths.approvals", + "auths.custody", + "auths.profiles.mcp", + "auths.profiles.http", + "auths.profile_kit", + "auths.runtime", + "auths.verify", + "auths.inspection", + "auths.integrations", + "auths.diagnostics", + "auths.observability", + "auths.testkit" + ], + "excludedModules": ["auths.advanced", "auths.native", "auths.mcp"], + "compatibilityWindow": false, + "adapterContract": "auths.python-adapter-contracts/1" +} diff --git a/bindings/python/src/application.rs b/bindings/python/src/application.rs new file mode 100644 index 00000000..5cd6db8a --- /dev/null +++ b/bindings/python/src/application.rs @@ -0,0 +1,736 @@ +#![allow(clippy::needless_pass_by_value, clippy::too_many_arguments)] + +use crate::authoring::{ + PyPrincipal, PySignedObject, PyTrustedContext, PyUnsignedObject, SignedObject, UnsignedObject, + value_error, +}; +use crate::result::{NativeVerificationResult, native_result, verify_sealed}; +use auths_author::{ + ProfilePlanCommitment, ProfilePlanMember, WorkflowProofBuilder, address_evidence, + prepare_profile_action, +}; +use auths_model::{ + Audience, BudgetAlgebraId, BudgetCeiling, CanonicalAction, CapabilityId, EvidenceTypeId, + MediaType, Permission, ProfileId, ProfileRef, ResourceId, +}; +use pyo3::{ + exceptions::{PyRuntimeError, PyTypeError, PyValueError}, + prelude::*, + types::PyBytes, +}; +use std::collections::HashSet; + +#[derive(Clone)] +#[pyclass( + name = "ApplicationAction", + frozen, + module = "auths._native", + skip_from_py_object +)] +pub struct PyApplicationAction { + canonical: CanonicalAction, + resource_namespace: ResourceId, + audience: Audience, +} + +#[pymethods] +impl PyApplicationAction { + #[getter] + fn profile_id(&self) -> &str { + self.canonical.profile().id().as_str() + } + + #[getter] + fn profile_version(&self) -> u16 { + self.canonical.profile().version() + } + + #[getter] + fn media_type(&self) -> &str { + self.canonical.media_type().as_str() + } + + #[getter] + fn body<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, self.canonical.body()) + } + + #[getter] + fn permission(&self) -> (String, String) { + ( + self.canonical.permission().capability().as_str().to_owned(), + self.canonical.permission().resource().as_str().to_owned(), + ) + } + + #[getter] + fn resource_namespace(&self) -> &str { + self.resource_namespace.as_str() + } + + #[getter] + fn audience(&self) -> &str { + self.audience.as_str() + } + + #[getter] + fn budget(&self) -> Option<(String, u64)> { + self.canonical + .requested_budget() + .map(|value| (value.algebra().as_str().to_owned(), value.value())) + } +} + +#[pyclass( + name = "ApplicationActionPreparation", + frozen, + module = "auths._native", + skip_from_py_object +)] +pub struct PyApplicationActionPreparation { + action: PyApplicationAction, + envelope: auths_model::ActionEnvelope, +} + +#[pymethods] +impl PyApplicationActionPreparation { + #[getter] + fn unsigned(&self) -> PyUnsignedObject { + PyUnsignedObject { + inner: UnsignedObject::Action(self.envelope.clone()), + } + } +} + +#[pyclass( + name = "ApplicationCommand", + module = "auths._native", + skip_from_py_object +)] +pub struct PyApplicationCommand { + action: Option, + authority_commitment: [u8; 32], + context_commitment: [u8; 32], +} + +#[pymethods] +#[allow(clippy::unused_self)] +impl PyApplicationCommand { + #[getter] + fn action_commitment<'py>(&self, py: Python<'py>) -> PyResult> { + Ok(PyBytes::new(py, &self.action_commitment_bytes()?)) + } + + #[getter] + fn authority_commitment<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.authority_commitment) + } + + #[getter] + fn context_commitment<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.context_commitment) + } + + #[getter] + fn profile_id(&self) -> PyResult<&str> { + Ok(self.action()?.canonical.profile().id().as_str()) + } + + #[getter] + fn profile_version(&self) -> PyResult { + Ok(self.action()?.canonical.profile().version()) + } + + fn __repr__(&self) -> &'static str { + if self.action.is_some() { + "ApplicationCommand()" + } else { + "ApplicationCommand()" + } + } + + fn __copy__(&self) -> PyResult<()> { + Err(command_error()) + } + fn __deepcopy__(&self, _memo: &Bound<'_, PyAny>) -> PyResult<()> { + Err(command_error()) + } + fn __reduce__(&self) -> PyResult<()> { + Err(command_error()) + } + fn __reduce_ex__(&self, _protocol: i32) -> PyResult<()> { + Err(command_error()) + } +} + +impl PyApplicationCommand { + fn action(&self) -> PyResult<&PyApplicationAction> { + self.action + .as_ref() + .ok_or_else(|| PyRuntimeError::new_err("application command has already been consumed")) + } + + fn action_commitment_bytes(&self) -> PyResult<[u8; 32]> { + application_action_commitment(self.action()?) + } +} + +#[pyclass( + name = "ApplicationPlanCommand", + module = "auths._native", + skip_from_py_object +)] +pub struct PyApplicationPlanCommand { + actions: Option>, + commitment: [u8; 32], + receipt_bindings: Vec<([u8; 32], [u8; 32], [u8; 32])>, +} + +#[pymethods] +#[allow(clippy::unused_self)] +impl PyApplicationPlanCommand { + #[getter] + fn count(&self) -> PyResult { + Ok(self.actions()?.len()) + } + + #[getter] + fn plan_commitment<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.commitment) + } + + #[getter] + fn receipt_bindings(&self) -> Vec<(Vec, Vec, Vec)> { + self.receipt_bindings + .iter() + .map(|(action, authority, context)| { + (action.to_vec(), authority.to_vec(), context.to_vec()) + }) + .collect() + } + + fn __repr__(&self) -> &'static str { + if self.actions.is_some() { + "ApplicationPlanCommand()" + } else { + "ApplicationPlanCommand()" + } + } + + fn __copy__(&self) -> PyResult<()> { + Err(plan_command_error()) + } + fn __deepcopy__(&self, _memo: &Bound<'_, PyAny>) -> PyResult<()> { + Err(plan_command_error()) + } + fn __reduce__(&self) -> PyResult<()> { + Err(plan_command_error()) + } +} + +impl PyApplicationPlanCommand { + fn actions(&self) -> PyResult<&[PyApplicationAction]> { + self.actions.as_deref().ok_or_else(|| { + PyRuntimeError::new_err("application plan command has already been consumed") + }) + } +} + +#[pyclass(name = "NativeApplicationPlan", frozen, module = "auths._native")] +pub struct PyNativeApplicationPlan { + commitment: [u8; 32], + members: Vec<[u8; 32]>, +} + +#[pymethods] +impl PyNativeApplicationPlan { + #[getter] + fn commitment<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.commitment) + } + + #[getter] + fn members(&self) -> Vec> { + self.members.iter().map(|value| value.to_vec()).collect() + } +} + +#[pyclass(name = "ApplicationGatewayCall", frozen, module = "auths._native")] +pub struct PyApplicationGatewayCall { + profile_id: String, + profile_version: u16, + media_type: String, + body: Vec, + permission: (String, String), + resource_namespace: String, + audience: String, + budget: Option<(String, u64)>, +} + +#[pymethods] +impl PyApplicationGatewayCall { + #[getter] + fn profile_id(&self) -> &str { + &self.profile_id + } + #[getter] + fn profile_version(&self) -> u16 { + self.profile_version + } + #[getter] + fn media_type(&self) -> &str { + &self.media_type + } + #[getter] + fn body<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.body) + } + #[getter] + fn permission(&self) -> (String, String) { + self.permission.clone() + } + #[getter] + fn resource_namespace(&self) -> &str { + &self.resource_namespace + } + #[getter] + fn audience(&self) -> &str { + &self.audience + } + #[getter] + fn budget(&self) -> Option<(String, u64)> { + self.budget.clone() + } +} + +#[pyfunction] +fn application_action( + profile_id: &str, + profile_version: u16, + media_type: &str, + body: &[u8], + capability: &str, + resource: &str, + budget: Option<(String, u64)>, + resource_namespace: &str, + audience: &str, +) -> PyResult { + let canonical = CanonicalAction::new( + ProfileRef::new( + ProfileId::parse(profile_id).map_err(value_error)?, + profile_version, + ) + .map_err(value_error)?, + MediaType::parse(media_type).map_err(value_error)?, + body.to_vec(), + Permission::new( + CapabilityId::parse(capability).map_err(value_error)?, + ResourceId::parse(resource).map_err(value_error)?, + ), + budget + .map(|(algebra, value)| { + Ok::(BudgetCeiling::new( + BudgetAlgebraId::parse(&algebra).map_err(value_error)?, + value, + )) + }) + .transpose()?, + ) + .map_err(value_error)?; + Ok(PyApplicationAction { + canonical, + resource_namespace: ResourceId::parse(resource_namespace).map_err(value_error)?, + audience: Audience::parse(audience).map_err(value_error)?, + }) +} + +#[pyfunction] +fn application_action_commitment_v1<'py>( + py: Python<'py>, + action: PyRef<'_, PyApplicationAction>, +) -> PyResult> { + Ok(PyBytes::new(py, &application_action_commitment(&action)?)) +} + +#[pyfunction] +fn commit_application_plan( + py: Python<'_>, + actions: Vec>, +) -> PyResult { + if actions.is_empty() || actions.len() > 256 { + return Err(PyValueError::new_err( + "application plan action count is outside native limits", + )); + } + let actions = actions + .iter() + .map(|value| value.borrow(py).clone()) + .collect::>(); + compatible(&actions)?; + let members = actions + .iter() + .map(plan_member) + .collect::>>()?; + let borrowed = members.iter().map(Vec::as_slice).collect::>(); + let first = actions.first().expect("non-empty"); + let commitment = ProfilePlanCommitment::commit( + first.canonical.profile().id().as_str(), + first.canonical.profile().version(), + &borrowed, + ) + .map_err(value_error)?; + Ok(PyNativeApplicationPlan { + commitment: *commitment.plan().as_bytes(), + members: commitment + .members() + .iter() + .map(|value| *value.as_bytes()) + .collect(), + }) +} + +#[pyfunction] +fn prepare_application_action( + action: PyRef<'_, PyApplicationAction>, + actor: PyRef<'_, PyPrincipal>, + terminal_grant: PyRef<'_, PySignedObject>, + challenge: &[u8], + evaluation_time: u64, +) -> PyResult { + let SignedObject::Grant(grant) = &terminal_grant.inner else { + return Err(PyTypeError::new_err( + "terminal grant must be a signed grant", + )); + }; + let challenge: [u8; 32] = challenge + .try_into() + .map_err(|_| PyValueError::new_err("challenge must contain 32 bytes"))?; + let prepared = prepare_profile_action( + action.canonical.clone(), + action.audience.clone(), + actor.inner.clone(), + grant, + challenge, + evaluation_time, + ) + .map_err(value_error)?; + let (canonical, envelope) = prepared.into_parts(); + Ok(PyApplicationActionPreparation { + action: PyApplicationAction { + canonical, + resource_namespace: action.resource_namespace.clone(), + audience: action.audience.clone(), + }, + envelope, + }) +} + +#[pyfunction] +fn authorize_application( + py: Python<'_>, + prepared: PyRef<'_, PyApplicationActionPreparation>, + signed_action: PyRef<'_, PySignedObject>, + grants: Vec>, + grant_evidence: Vec)>>, + action_evidence: Vec<(String, String, Vec)>, + context: PyRef<'_, PyTrustedContext>, +) -> PyResult<(NativeVerificationResult, Option)> { + if grants.len() != grant_evidence.len() { + return Err(PyValueError::new_err( + "each grant requires one evidence collection", + )); + } + let SignedObject::Action(action) = &signed_action.inner else { + return Err(PyTypeError::new_err("signed action must be an action")); + }; + if action.envelope() != &prepared.envelope { + return Err(PyValueError::new_err( + "signed action does not match its native preparation", + )); + } + let mut builder = WorkflowProofBuilder::new(); + for (grant, evidence) in grants.iter().zip(grant_evidence) { + let grant = grant.borrow(py); + let SignedObject::Grant(grant) = &grant.inner else { + return Err(PyTypeError::new_err("grant chain contains a non-grant")); + }; + let index = builder.push_grant(grant.clone()).map_err(value_error)?; + for (evidence_type, media_type, bytes) in evidence { + builder + .bind_grant_evidence(index, evidence_object(&evidence_type, &media_type, bytes)?) + .map_err(value_error)?; + } + } + for (evidence_type, media_type, bytes) in action_evidence { + builder + .bind_action_evidence(evidence_object(&evidence_type, &media_type, bytes)?) + .map_err(value_error)?; + } + let artifacts = builder + .finish(action, &prepared.action.canonical, &context.inner) + .map_err(value_error)?; + let proof = auths_codec::encode_bundle(artifacts.proof()).map_err(value_error)?; + let canonical = + auths_codec::encode_canonical_action(&prepared.action.canonical).map_err(value_error)?; + let context = auths_codec::encode_verifier_context(artifacts.context()).map_err(value_error)?; + let authority_commitment = *auths_codec::proof_digest(artifacts.proof()) + .map_err(value_error)? + .as_bytes(); + let context_commitment = *auths_codec::context_digest(artifacts.context()) + .map_err(value_error)? + .as_bytes(); + let sealed = verify_sealed(&proof, &canonical, &context)?; + let command = sealed + .action() + .map(|verified| { + if verified.canonical_action() != &prepared.action.canonical { + return Err(PyValueError::new_err( + "verified application action changed meaning", + )); + } + Ok(PyApplicationCommand { + action: Some(prepared.action.clone()), + authority_commitment, + context_commitment, + }) + }) + .transpose()?; + Ok((native_result(py, sealed)?, command)) +} + +#[pyfunction] +fn seal_application_plan_command( + py: Python<'_>, + commands: Vec>, + expected_profile_id: &str, + expected_profile_version: u16, + expected_commitment: &[u8], +) -> PyResult { + if commands.is_empty() || commands.len() > 256 { + return Err(PyValueError::new_err( + "application plan command count is outside native limits", + )); + } + let expected: [u8; 32] = expected_commitment + .try_into() + .map_err(|_| PyValueError::new_err("plan commitment must contain 32 bytes"))?; + let mut identities = HashSet::with_capacity(commands.len()); + if commands + .iter() + .any(|command| !identities.insert(command.as_ptr() as usize)) + { + return Err(PyValueError::new_err( + "application plan contains duplicate command handles", + )); + } + let actions = commands + .iter() + .map(|command| { + let command = command.borrow(py); + let action = command.action()?; + if action.canonical.profile().id().as_str() != expected_profile_id + || action.canonical.profile().version() != expected_profile_version + { + return Err(PyTypeError::new_err( + "application command belongs to another profile", + )); + } + Ok(action.clone()) + }) + .collect::>>()?; + let members = actions + .iter() + .map(plan_member) + .collect::>>()?; + let borrowed = members.iter().map(Vec::as_slice).collect::>(); + let commitment = + ProfilePlanCommitment::commit(expected_profile_id, expected_profile_version, &borrowed) + .map_err(value_error)?; + if commitment.plan().as_bytes() != &expected { + return Err(PyValueError::new_err( + "verified commands do not match the application plan", + )); + } + let receipt_bindings = commands + .iter() + .map(|command| { + let command = command.borrow(py); + Ok(( + command.action_commitment_bytes()?, + command.authority_commitment, + command.context_commitment, + )) + }) + .collect::>>()?; + for command in &commands { + command.borrow_mut(py).action.take(); + } + Ok(PyApplicationPlanCommand { + actions: Some(actions), + commitment: expected, + receipt_bindings, + }) +} + +#[pyfunction] +fn consume_application_command( + mut command: PyRefMut<'_, PyApplicationCommand>, + expected_profile_id: &str, + expected_profile_version: u16, +) -> PyResult { + let action = command.action()?; + matching_profile(action, expected_profile_id, expected_profile_version)?; + let action = command + .action + .take() + .ok_or_else(|| PyRuntimeError::new_err("application command has already been consumed"))?; + Ok(gateway_call(action)) +} + +#[pyfunction] +fn consume_application_plan_command( + mut command: PyRefMut<'_, PyApplicationPlanCommand>, + expected_profile_id: &str, + expected_profile_version: u16, +) -> PyResult> { + for action in command.actions()? { + matching_profile(action, expected_profile_id, expected_profile_version)?; + } + command + .actions + .take() + .ok_or_else(|| { + PyRuntimeError::new_err("application plan command has already been consumed") + })? + .into_iter() + .map(|value| Ok(gateway_call(value))) + .collect() +} + +fn compatible(actions: &[PyApplicationAction]) -> PyResult<()> { + let first = actions + .first() + .ok_or_else(|| PyValueError::new_err("application plan is empty"))?; + if actions.iter().any(|action| { + action.canonical.profile() != first.canonical.profile() + || action.resource_namespace != first.resource_namespace + || action.audience != first.audience + || action + .canonical + .requested_budget() + .map(BudgetCeiling::algebra) + != first + .canonical + .requested_budget() + .map(BudgetCeiling::algebra) + }) { + return Err(PyValueError::new_err( + "application plan members have incompatible authority", + )); + } + actions.iter().try_fold(0_u64, |total, action| { + total + .checked_add( + action + .canonical + .requested_budget() + .map_or(0, BudgetCeiling::value), + ) + .ok_or_else(|| { + PyValueError::new_err("application plan aggregate budget exceeds bounds") + }) + })?; + Ok(()) +} + +fn matching_profile(action: &PyApplicationAction, id: &str, version: u16) -> PyResult<()> { + if action.canonical.profile().id().as_str() != id + || action.canonical.profile().version() != version + { + return Err(PyTypeError::new_err( + "application command belongs to another profile", + )); + } + Ok(()) +} + +fn plan_member(action: &PyApplicationAction) -> PyResult> { + ProfilePlanMember::encode( + &action.canonical, + &action.resource_namespace, + &action.audience, + ) + .map_err(value_error) +} + +fn application_action_commitment(action: &PyApplicationAction) -> PyResult<[u8; 32]> { + let encoded = auths_codec::encode_canonical_action(&action.canonical).map_err(value_error)?; + Ok( + *auths_codec::domain_commitment("auths.canonical-action.v1", &encoded) + .map_err(value_error)? + .as_bytes(), + ) +} + +fn gateway_call(action: PyApplicationAction) -> PyApplicationGatewayCall { + PyApplicationGatewayCall { + profile_id: action.canonical.profile().id().as_str().to_owned(), + profile_version: action.canonical.profile().version(), + media_type: action.canonical.media_type().as_str().to_owned(), + body: action.canonical.body().to_vec(), + permission: ( + action + .canonical + .permission() + .capability() + .as_str() + .to_owned(), + action.canonical.permission().resource().as_str().to_owned(), + ), + resource_namespace: action.resource_namespace.as_str().to_owned(), + audience: action.audience.as_str().to_owned(), + budget: action + .canonical + .requested_budget() + .map(|value| (value.algebra().as_str().to_owned(), value.value())), + } +} + +fn evidence_object( + evidence_type: &str, + media_type: &str, + bytes: Vec, +) -> PyResult { + address_evidence( + EvidenceTypeId::parse(evidence_type).map_err(value_error)?, + MediaType::parse(media_type).map_err(value_error)?, + bytes, + ) + .map_err(value_error) +} + +fn command_error() -> PyErr { + PyTypeError::new_err("ApplicationCommand is a non-copyable native capability") +} + +fn plan_command_error() -> PyErr { + PyTypeError::new_err("ApplicationPlanCommand is a non-copyable native capability") +} + +pub fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_function(wrap_pyfunction!(application_action, module)?)?; + module.add_function(wrap_pyfunction!(application_action_commitment_v1, module)?)?; + module.add_function(wrap_pyfunction!(commit_application_plan, module)?)?; + module.add_function(wrap_pyfunction!(prepare_application_action, module)?)?; + module.add_function(wrap_pyfunction!(authorize_application, module)?)?; + module.add_function(wrap_pyfunction!(seal_application_plan_command, module)?)?; + module.add_function(wrap_pyfunction!(consume_application_command, module)?)?; + module.add_function(wrap_pyfunction!(consume_application_plan_command, module)?)?; + Ok(()) +} diff --git a/bindings/python/src/authoring.rs b/bindings/python/src/authoring.rs new file mode 100644 index 00000000..d3538848 --- /dev/null +++ b/bindings/python/src/authoring.rs @@ -0,0 +1,1467 @@ +#![allow( + clippy::needless_pass_by_value, + clippy::redundant_closure_for_method_calls, + clippy::struct_excessive_bools, + clippy::unused_self +)] + +use auths_author::{ + AuthorityDiff, ExternalSigningRequest, GrantPlan, GrantRequest, OverGrantingWarning, + PlanBuilder, commit_plan_approval as commit_plan_approval_native, plan_child_grant, + prepare_action, prepare_grant, prepare_grant_status, prepare_principal_status, + prepare_profile_action, +}; +use auths_model::{ + ActionConstraint, ActionEnvelope, AssuranceClaimId, AssurancePolicy, AssurancePolicyId, + AssuranceQuantifier, AssuranceRequirement, Audience, AudienceSet, AuthorizationPlan, + BudgetAlgebraId, BudgetCeiling, CanonicalAction, Challenge, ChannelBindingId, + CompositionRequirement, CriticalExtension, CriticalExtensions, Digest, EvidenceId, + FreshnessLimit, GrantId, GrantState, GrantStatusSnapshot, GrantStatusStatement, + ParticipantRole, Permission, PermissionSet, PrincipalId, PrincipalMethodId, PrincipalState, + PrincipalStatusSnapshot, PrincipalStatusStatement, ProfileId, ProfileRef, ProofRef, PurposeId, + ResourceId, SignatureBytes, SignatureDescriptor, SignatureSuiteId, SignedAction, SignedGrant, + SignedGrantStatus, SignedPrincipalStatus, StatusMethodId, StatusPolicy, StatusSnapshotId, + StatusTrustRule, Timestamp, TrustAnchor, TrustAnchorId, ValidityWindow, VerificationMethod, + VerifierConfigurationId, VerifierContext, VerifierLimits, +}; +use auths_ports::{PrincipalMethod, SignatureSuite}; +use auths_profile_api::ActionProfile; +use auths_profile_mcp::{McpProfile, McpToolCall}; +use pyo3::{ + exceptions::{PyRuntimeError, PyTypeError, PyValueError}, + prelude::*, + types::PyBytes, +}; +use serde_json::Value; + +#[derive(Clone)] +#[pyclass( + name = "Principal", + frozen, + module = "auths._native", + skip_from_py_object +)] +pub struct PyPrincipal { + pub(crate) inner: PrincipalId, +} + +#[pymethods] +impl PyPrincipal { + #[new] + fn new(value: &str) -> PyResult { + Ok(Self { + inner: PrincipalId::parse(value).map_err(value_error)?, + }) + } + + #[getter] + fn value(&self) -> &str { + self.inner.as_str() + } + + fn __str__(&self) -> &str { + self.inner.as_str() + } + + fn __repr__(&self) -> String { + format!("Principal({:?})", self.inner.as_str()) + } +} + +#[derive(Clone)] +pub(crate) enum UnsignedObject { + Grant(auths_model::GrantStatement), + Action(ActionEnvelope), + PrincipalStatus(PrincipalStatusStatement), + GrantStatus(GrantStatusStatement), +} + +impl UnsignedObject { + const fn kind(&self) -> &'static str { + match self { + Self::Grant(_) => "grant", + Self::Action(_) => "action", + Self::PrincipalStatus(_) => "principal-status", + Self::GrantStatus(_) => "grant-status", + } + } +} + +#[pyclass(name = "UnsignedObject", frozen, module = "auths._native")] +pub struct PyUnsignedObject { + pub(crate) inner: UnsignedObject, +} + +#[pymethods] +impl PyUnsignedObject { + #[getter] + fn kind(&self) -> &'static str { + self.inner.kind() + } + + fn __repr__(&self) -> String { + format!("UnsignedObject(kind={:?})", self.inner.kind()) + } +} + +#[derive(Clone)] +pub(crate) enum SignedObject { + Grant(SignedGrant), + Action(SignedAction), + PrincipalStatus(SignedPrincipalStatus), + GrantStatus(SignedGrantStatus), +} + +impl SignedObject { + const fn kind(&self) -> &'static str { + match self { + Self::Grant(_) => "grant", + Self::Action(_) => "action", + Self::PrincipalStatus(_) => "principal-status", + Self::GrantStatus(_) => "grant-status", + } + } +} + +#[pyclass(name = "SignedObject", frozen, module = "auths._native")] +pub struct PySignedObject { + pub(crate) inner: SignedObject, +} + +#[pymethods] +impl PySignedObject { + #[getter] + fn kind(&self) -> &'static str { + self.inner.kind() + } + + fn __repr__(&self) -> String { + format!("SignedObject(kind={:?})", self.inner.kind()) + } +} + +#[derive(Clone)] +struct ScopeParts { + subject: PrincipalId, + profile: ProfileRef, + permissions: PermissionSet, + validity: ValidityWindow, + audiences: AudienceSet, + action_constraint: ActionConstraint, + budget_ceiling: Option, + remaining_depth: u16, + status_policy: StatusPolicy, + assurance_floor: AssurancePolicyId, + extensions: CriticalExtensions, +} + +impl ScopeParts { + fn request(&self) -> GrantRequest { + GrantRequest::new( + self.subject.clone(), + self.profile.clone(), + self.permissions.clone(), + self.validity, + self.audiences.clone(), + self.action_constraint.clone(), + self.budget_ceiling.clone(), + self.remaining_depth, + self.status_policy.clone(), + self.assurance_floor.clone(), + self.extensions.clone(), + ) + } + + fn statement(&self, issuer: PrincipalId) -> auths_model::GrantStatement { + auths_model::GrantStatement::new( + issuer, + self.subject.clone(), + self.profile.clone(), + self.permissions.clone(), + self.validity, + self.audiences.clone(), + self.action_constraint.clone(), + self.budget_ceiling.clone(), + self.remaining_depth, + None, + self.status_policy.clone(), + self.assurance_floor.clone(), + self.extensions.clone(), + ) + } +} + +#[pyclass(name = "GrantRequest", frozen, module = "auths._native")] +pub struct PyGrantRequest { + scope: ScopeParts, +} + +#[pymethods] +impl PyGrantRequest { + #[new] + #[allow(clippy::too_many_arguments)] + fn new( + subject: PyRef<'_, PyPrincipal>, + profile_id: &str, + profile_version: u16, + permissions: Vec<(String, String)>, + not_before: u64, + expires_at: u64, + audiences: Vec, + body_digests: Option>>, + budget: Option<(String, u64)>, + remaining_depth: u16, + status: Option<(String, u64)>, + assurance_floor: &str, + extensions: Vec<(String, Vec)>, + ) -> PyResult { + Ok(Self { + scope: scope_parts( + subject.inner.clone(), + profile_id, + profile_version, + permissions, + not_before, + expires_at, + audiences, + body_digests, + budget, + remaining_depth, + status, + assurance_floor, + extensions, + )?, + }) + } +} + +#[pyclass(name = "AuthorityDiff", frozen, module = "auths._native")] +pub struct PyAuthorityDiff { + removed_permissions: usize, + removed_audiences: usize, + validity_shortened: bool, + action_narrowed: bool, + budget_narrowed: bool, + status_narrowed: bool, + parent_depth: u16, + child_depth: u16, +} + +#[pymethods] +impl PyAuthorityDiff { + #[getter] + fn removed_permissions(&self) -> usize { + self.removed_permissions + } + + #[getter] + fn removed_audiences(&self) -> usize { + self.removed_audiences + } + + #[getter] + fn validity_shortened(&self) -> bool { + self.validity_shortened + } + + #[getter] + fn action_narrowed(&self) -> bool { + self.action_narrowed + } + + #[getter] + fn budget_narrowed(&self) -> bool { + self.budget_narrowed + } + + #[getter] + fn status_narrowed(&self) -> bool { + self.status_narrowed + } + + #[getter] + fn delegation_depth(&self) -> (u16, u16) { + (self.parent_depth, self.child_depth) + } +} + +#[pyclass(name = "GrantPlan", frozen, module = "auths._native")] +pub struct PyGrantPlan { + pub(crate) inner: GrantPlan, +} + +#[pymethods] +impl PyGrantPlan { + #[getter] + fn diff(&self) -> PyAuthorityDiff { + authority_diff(self.inner.diff()) + } + + #[getter] + fn warnings(&self) -> Vec<&'static str> { + self.inner + .warnings() + .iter() + .copied() + .map(warning_label) + .collect() + } + + #[getter] + fn unsigned(&self) -> PyUnsignedObject { + PyUnsignedObject { + inner: UnsignedObject::Grant(self.inner.statement().clone()), + } + } +} + +#[pyfunction] +fn root_grant( + issuer: PyRef<'_, PyPrincipal>, + request: PyRef<'_, PyGrantRequest>, +) -> PyUnsignedObject { + PyUnsignedObject { + inner: UnsignedObject::Grant(request.scope.statement(issuer.inner.clone())), + } +} + +#[pyfunction] +fn plan_child( + parent: PyRef<'_, PySignedObject>, + request: PyRef<'_, PyGrantRequest>, +) -> PyResult { + let SignedObject::Grant(parent) = &parent.inner else { + return Err(PyTypeError::new_err("parent must be a signed grant")); + }; + Ok(PyGrantPlan { + inner: plan_child_grant(parent.statement(), request.scope.request()) + .map_err(value_error)?, + }) +} + +#[pyfunction] +fn plan_child_statement( + parent: PyRef<'_, PyUnsignedObject>, + request: PyRef<'_, PyGrantRequest>, +) -> PyResult { + let UnsignedObject::Grant(parent) = &parent.inner else { + return Err(PyTypeError::new_err("parent must be an unsigned grant")); + }; + Ok(PyGrantPlan { + inner: plan_child_grant(parent, request.scope.request()).map_err(value_error)?, + }) +} + +#[pyfunction] +fn grant_request_from_statement( + statement: PyRef<'_, PyUnsignedObject>, +) -> PyResult { + let UnsignedObject::Grant(statement) = &statement.inner else { + return Err(PyTypeError::new_err("statement must be an unsigned grant")); + }; + Ok(PyGrantRequest { + scope: scope_from_statement(statement), + }) +} + +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn principal_status_statement( + method: &str, + principal: PyRef<'_, PyPrincipal>, + purpose: &str, + state: &str, + sequence: u64, + observed_at: u64, + valid_until: u64, + issuer: PyRef<'_, PyPrincipal>, + extensions: Vec<(String, Vec)>, +) -> PyResult { + let statement = PrincipalStatusStatement::new( + StatusMethodId::parse(method).map_err(value_error)?, + principal.inner.clone(), + PurposeId::parse(purpose).map_err(value_error)?, + principal_state(state)?, + sequence, + Timestamp::new(observed_at), + Timestamp::new(valid_until), + issuer.inner.clone(), + critical_extensions(extensions)?, + ) + .map_err(value_error)?; + Ok(PyUnsignedObject { + inner: UnsignedObject::PrincipalStatus(statement), + }) +} + +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn grant_status_statement( + method: &str, + grant_id: &[u8], + state: &str, + sequence: u64, + observed_at: u64, + valid_until: u64, + issuer: PyRef<'_, PyPrincipal>, + extensions: Vec<(String, Vec)>, +) -> PyResult { + let statement = GrantStatusStatement::new( + StatusMethodId::parse(method).map_err(value_error)?, + GrantId::new(array32(grant_id, "grant id")?), + grant_state(state)?, + sequence, + Timestamp::new(observed_at), + Timestamp::new(valid_until), + issuer.inner.clone(), + critical_extensions(extensions)?, + ) + .map_err(value_error)?; + Ok(PyUnsignedObject { + inner: UnsignedObject::GrantStatus(statement), + }) +} + +enum SigningRequest { + Grant(ExternalSigningRequest), + Action(ExternalSigningRequest), + PrincipalStatus(ExternalSigningRequest), + GrantStatus(ExternalSigningRequest), +} + +impl SigningRequest { + fn request_id(&self) -> String { + match self { + Self::Grant(value) => value.request_id(), + Self::Action(value) => value.request_id(), + Self::PrincipalStatus(value) => value.request_id(), + Self::GrantStatus(value) => value.request_id(), + } + } + + fn object_id(&self) -> [u8; 32] { + match self { + Self::Grant(value) => *value.object_id().as_bytes(), + Self::Action(value) => *value.object_id().as_bytes(), + Self::PrincipalStatus(value) => *value.object_id().as_bytes(), + Self::GrantStatus(value) => *value.object_id().as_bytes(), + } + } + + fn object_kind(&self) -> &'static str { + match self { + Self::Grant(value) => value.object_id().label(), + Self::Action(value) => value.object_id().label(), + Self::PrincipalStatus(value) => value.object_id().label(), + Self::GrantStatus(value) => value.object_id().label(), + } + } + + fn signing_preimage(&self) -> &[u8] { + match self { + Self::Grant(value) => value.signing_preimage(), + Self::Action(value) => value.signing_preimage(), + Self::PrincipalStatus(value) => value.signing_preimage(), + Self::GrantStatus(value) => value.signing_preimage(), + } + } + + fn transaction_digest(&self) -> Digest { + match self { + Self::Grant(value) => value.transaction_digest(), + Self::Action(value) => value.transaction_digest(), + Self::PrincipalStatus(value) => value.transaction_digest(), + Self::GrantStatus(value) => value.transaction_digest(), + } + } +} + +#[pyclass(name = "SigningRequest", module = "auths._native")] +pub struct PySigningRequest { + inner: Option, +} + +#[pymethods] +impl PySigningRequest { + #[getter] + fn object_kind(&self) -> PyResult<&'static str> { + Ok(self.request()?.object_kind()) + } + + #[getter] + fn request_id(&self) -> PyResult { + Ok(self.request()?.request_id()) + } + + #[getter] + fn object_id<'py>(&self, py: Python<'py>) -> PyResult> { + Ok(PyBytes::new(py, &self.request()?.object_id())) + } + + #[getter] + fn signing_preimage<'py>(&self, py: Python<'py>) -> PyResult> { + Ok(PyBytes::new(py, self.request()?.signing_preimage())) + } + + #[getter] + fn transaction_digest<'py>(&self, py: Python<'py>) -> PyResult> { + Ok(PyBytes::new( + py, + self.request()?.transaction_digest().as_bytes(), + )) + } + + fn complete(&mut self, signature: &[u8]) -> PyResult { + let signature = SignatureBytes::new(signature.to_vec()).map_err(value_error)?; + let request = self + .inner + .take() + .ok_or_else(|| PyRuntimeError::new_err("signing request was already completed"))?; + let inner = match request { + SigningRequest::Grant(value) => SignedObject::Grant(value.complete(signature)), + SigningRequest::Action(value) => SignedObject::Action(value.complete(signature)), + SigningRequest::PrincipalStatus(value) => { + SignedObject::PrincipalStatus(value.complete(signature)) + } + SigningRequest::GrantStatus(value) => { + SignedObject::GrantStatus(value.complete(signature)) + } + }; + Ok(PySignedObject { inner }) + } +} + +impl PySigningRequest { + fn request(&self) -> PyResult<&SigningRequest> { + self.inner + .as_ref() + .ok_or_else(|| PyRuntimeError::new_err("signing request was already completed")) + } +} + +#[pyfunction] +fn prepare_signing( + unsigned: PyRef<'_, PyUnsignedObject>, + principal_method: &str, + verification_method: &str, + suite: &str, +) -> PyResult { + let descriptor = signing_descriptor(principal_method, verification_method, suite)?; + let inner = match &unsigned.inner { + UnsignedObject::Grant(value) => { + SigningRequest::Grant(prepare_grant(value.clone(), descriptor).map_err(value_error)?) + } + UnsignedObject::Action(value) => { + SigningRequest::Action(prepare_action(value.clone(), descriptor).map_err(value_error)?) + } + UnsignedObject::PrincipalStatus(value) => SigningRequest::PrincipalStatus( + prepare_principal_status(value.clone(), descriptor).map_err(value_error)?, + ), + UnsignedObject::GrantStatus(value) => SigningRequest::GrantStatus( + prepare_grant_status(value.clone(), descriptor).map_err(value_error)?, + ), + }; + Ok(PySigningRequest { inner: Some(inner) }) +} + +#[pyclass(name = "AuthorizationPlan", frozen, module = "auths._native")] +pub struct PyAuthorizationPlan { + inner: AuthorizationPlan, +} + +#[pymethods] +impl PyAuthorizationPlan { + #[getter] + fn plan_id<'py>(&self, py: Python<'py>) -> PyResult> { + let id = auths_codec::plan_id(&self.inner).map_err(value_error)?; + Ok(PyBytes::new(py, id.as_bytes())) + } + + #[getter] + fn shape(&self) -> PyResult<(usize, usize)> { + let shape = self + .inner + .validate(&VerifierLimits::default_deployment()) + .map_err(value_error)?; + Ok((shape.leaves().len(), shape.maximum_depth())) + } +} + +#[pyclass(name = "AuthorizationPlanBuilder", frozen, module = "auths._native")] +pub struct PyAuthorizationPlanBuilder; + +#[pymethods] +impl PyAuthorizationPlanBuilder { + #[new] + fn new() -> Self { + Self + } + + fn proof(&self, reference: &[u8]) -> PyResult { + let limits = VerifierLimits::default_deployment(); + Ok(PyAuthorizationPlan { + inner: PlanBuilder::new(&limits) + .proof(ProofRef::new(array32(reference, "proof reference")?)), + }) + } + + fn all_of( + &self, + py: Python<'_>, + members: Vec>, + ) -> PyResult { + build_plan(py, members, |builder, values| builder.all_of(values)) + } + + fn any_of( + &self, + py: Python<'_>, + members: Vec>, + ) -> PyResult { + build_plan(py, members, |builder, values| builder.any_of(values)) + } + + fn threshold( + &self, + py: Python<'_>, + required: u16, + members: Vec>, + ) -> PyResult { + build_plan(py, members, |builder, values| { + builder.k_of_n(required, values) + }) + } +} + +#[pyclass(name = "McpAction", frozen, module = "auths._native")] +pub struct PyMcpAction { + pub(crate) canonical: CanonicalAction, + pub(crate) envelope: ActionEnvelope, + pub(crate) arguments_json: Vec, + pub(crate) audience: String, + pub(crate) resource: String, + pub(crate) display_digest_hex: String, + pub(crate) review_title: String, + pub(crate) review_fields: Vec<(String, String)>, +} + +#[pymethods] +impl PyMcpAction { + #[getter] + fn unsigned(&self) -> PyUnsignedObject { + PyUnsignedObject { + inner: UnsignedObject::Action(self.envelope.clone()), + } + } + + #[getter] + fn audience(&self) -> &str { + &self.audience + } + + #[getter] + fn resource(&self) -> &str { + &self.resource + } + + #[getter] + fn display_digest_hex(&self) -> &str { + &self.display_digest_hex + } + + #[getter] + fn review_title(&self) -> &str { + &self.review_title + } + + #[getter] + fn review_fields(&self) -> Vec<(String, String)> { + self.review_fields.clone() + } +} + +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn prepare_mcp_action( + service: &str, + name: &str, + arguments_json: &[u8], + actor: PyRef<'_, PyPrincipal>, + terminal_grant: PyRef<'_, PySignedObject>, + challenge: &[u8], + evaluation_time: u64, +) -> PyResult { + let Value::Object(arguments) = + serde_json::from_slice::(arguments_json).map_err(value_error)? + else { + return Err(PyValueError::new_err("MCP arguments must be a JSON object")); + }; + let canonical_arguments = serde_json_canonicalizer::to_vec(&arguments).map_err(value_error)?; + if canonical_arguments != arguments_json { + return Err(PyValueError::new_err( + "MCP arguments must use canonical JSON encoding", + )); + } + let SignedObject::Grant(terminal_grant) = &terminal_grant.inner else { + return Err(PyTypeError::new_err( + "terminal grant must be a signed grant", + )); + }; + let call = McpToolCall::new(service, name, arguments).map_err(value_error)?; + let profile = McpProfile; + let canonical = profile + .canonicalize(&call.canonical_bytes().map_err(value_error)?) + .map_err(value_error)?; + let display = profile.review_display(&canonical).map_err(value_error)?; + let prepared = prepare_profile_action( + canonical, + call.audience().map_err(value_error)?, + actor.inner.clone(), + terminal_grant, + array32(challenge, "challenge")?, + evaluation_time, + ) + .map_err(value_error)?; + let (canonical, envelope) = prepared.into_parts(); + Ok(PyMcpAction { + arguments_json: canonical_arguments, + audience: call.audience().map_err(value_error)?.to_string(), + resource: canonical.permission().resource().to_string(), + display_digest_hex: display.canonical_digest_hex().to_owned(), + review_title: display.title().to_owned(), + review_fields: display.fields().to_vec(), + canonical, + envelope, + }) +} + +#[pyclass(name = "AssurancePolicy", frozen, module = "auths._native")] +pub struct PyAssurancePolicy { + inner: AssurancePolicy, +} + +#[pymethods] +impl PyAssurancePolicy { + #[new] + fn new( + identifier: &str, + requirements: Vec<(String, String, String, Option)>, + ) -> PyResult { + let requirements = requirements + .into_iter() + .map(|(role, quantifier, claim, maximum_age)| { + Ok(AssuranceRequirement::new( + participant_role(&role)?, + assurance_quantifier(&quantifier)?, + AssuranceClaimId::parse(&claim).map_err(value_error)?, + maximum_age + .map(FreshnessLimit::new) + .transpose() + .map_err(value_error)?, + )) + }) + .collect::>>()?; + Ok(Self { + inner: AssurancePolicy::new( + AssurancePolicyId::parse(identifier).map_err(value_error)?, + requirements, + ) + .map_err(value_error)?, + }) + } +} + +#[pyclass(name = "TrustAnchor", frozen, module = "auths._native")] +pub struct PyTrustAnchor { + inner: TrustAnchor, +} + +#[pymethods] +impl PyTrustAnchor { + #[new] + #[allow(clippy::too_many_arguments)] + fn new( + identifier: &str, + principal: PyRef<'_, PyPrincipal>, + accepted_methods: Vec, + profiles: Vec<(String, u16)>, + permissions: Vec<(String, String)>, + resource_namespaces: Vec, + audiences: Vec, + not_before: u64, + expires_at: u64, + budget: Option<(String, u64)>, + max_delegation_depth: u16, + assurance_policy: &str, + status: Option<(String, u64)>, + ) -> PyResult { + Ok(Self { + inner: TrustAnchor::new( + TrustAnchorId::parse(identifier).map_err(value_error)?, + principal.inner.clone(), + accepted_methods + .into_iter() + .map(|value| PrincipalMethodId::parse(&value).map_err(value_error)) + .collect::>>()?, + profiles + .into_iter() + .map(|(identifier, version)| { + ProfileRef::new( + ProfileId::parse(&identifier).map_err(value_error)?, + version, + ) + .map_err(value_error) + }) + .collect::>>()?, + permission_set(permissions)?, + resource_namespaces + .into_iter() + .map(|value| ResourceId::parse(&value).map_err(value_error)) + .collect::>>()?, + audience_set(audiences)?, + ValidityWindow::new(Timestamp::new(not_before), Timestamp::new(expires_at)) + .map_err(value_error)?, + budget_ceiling(budget)?, + max_delegation_depth, + AssurancePolicyId::parse(assurance_policy).map_err(value_error)?, + status_policy(status)?, + ) + .map_err(value_error)?, + }) + } +} + +#[derive(Clone)] +enum StatusSnapshot { + Principal(PrincipalStatusSnapshot), + Grant(GrantStatusSnapshot), +} + +#[pyclass(name = "StatusSnapshot", frozen, module = "auths._native")] +pub struct PyStatusSnapshot { + inner: StatusSnapshot, +} + +#[pymethods] +impl PyStatusSnapshot { + #[getter] + fn kind(&self) -> &'static str { + match self.inner { + StatusSnapshot::Principal(_) => "principal", + StatusSnapshot::Grant(_) => "grant", + } + } +} + +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn status_snapshot( + py: Python<'_>, + kind: &str, + identifier: &[u8], + observed_at: u64, + valid_until: u64, + statements: Vec>, + checkpoints: Vec>, + trust: Vec<(String, String, u64)>, +) -> PyResult { + let id = StatusSnapshotId::new(array32(identifier, "status snapshot id")?); + let checkpoints = checkpoints + .into_iter() + .map(|value| array32(&value, "status checkpoint").map(EvidenceId::new)) + .collect::>>()?; + let trust = trust + .into_iter() + .map(|(method, issuer, sequence_floor)| { + Ok(StatusTrustRule::new( + StatusMethodId::parse(&method).map_err(value_error)?, + PrincipalId::parse(&issuer).map_err(value_error)?, + sequence_floor, + )) + }) + .collect::>>()?; + let inner = match kind { + "principal" => StatusSnapshot::Principal( + PrincipalStatusSnapshot::with_trust( + id, + Timestamp::new(observed_at), + Timestamp::new(valid_until), + statements + .iter() + .map(|value| match &value.borrow(py).inner { + SignedObject::PrincipalStatus(statement) => Ok(statement.clone()), + _ => Err(PyTypeError::new_err( + "principal snapshot requires principal-status statements", + )), + }) + .collect::>>()?, + checkpoints, + trust, + ) + .map_err(value_error)?, + ), + "grant" => StatusSnapshot::Grant( + GrantStatusSnapshot::with_trust( + id, + Timestamp::new(observed_at), + Timestamp::new(valid_until), + statements + .iter() + .map(|value| match &value.borrow(py).inner { + SignedObject::GrantStatus(statement) => Ok(statement.clone()), + _ => Err(PyTypeError::new_err( + "grant snapshot requires grant-status statements", + )), + }) + .collect::>>()?, + checkpoints, + trust, + ) + .map_err(value_error)?, + ), + _ => { + return Err(PyValueError::new_err( + "status kind must be principal or grant", + )); + } + }; + Ok(PyStatusSnapshot { inner }) +} + +#[pyclass(name = "TrustedContext", frozen, module = "auths._native")] +pub struct PyTrustedContext { + pub(crate) inner: VerifierContext, +} + +#[pymethods] +impl PyTrustedContext { + #[getter] + fn configuration<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, self.inner.configuration().as_bytes()) + } + + fn bind_request( + &self, + audience: &str, + challenge: &[u8], + evaluation_time: u64, + ) -> PyResult { + Ok(Self { + inner: self + .inner + .for_request( + Audience::parse(audience).map_err(value_error)?, + Challenge::new(array32(challenge, "challenge")?), + Timestamp::new(evaluation_time), + ) + .map_err(value_error)?, + }) + } +} + +#[pyfunction] +#[allow(clippy::too_many_arguments)] +fn compile_trusted_context( + py: Python<'_>, + configuration: &[u8], + expected_plan: Option>, + minimum_authorized_branches: u16, + minimum_distinct_actors: u16, + minimum_distinct_roots: u16, + anchors: Vec>, + assurance_policy: PyRef<'_, PyAssurancePolicy>, + principal_status: Option>, + grant_status: Option>, + channel_policy: &str, + evidence_types: Vec, + critical_extensions: Vec, +) -> PyResult { + let expected_plan = expected_plan + .as_ref() + .map(|plan| auths_codec::plan_id(&plan.borrow(py).inner).map_err(value_error)) + .transpose()?; + let composition = CompositionRequirement::new( + expected_plan, + minimum_authorized_branches, + minimum_distinct_actors, + minimum_distinct_roots, + ) + .map_err(value_error)?; + let anchors = anchors + .iter() + .map(|anchor| anchor.borrow(py).inner.clone()) + .collect(); + let mut builder = auths_sdk::TrustedContextBuilder::new( + VerifierConfigurationId::new(array32(configuration, "configuration")?), + composition, + anchors, + assurance_policy.inner.clone(), + ) + .map_err(value_error)?; + if let Some(snapshot) = principal_status { + match &snapshot.borrow(py).inner { + StatusSnapshot::Principal(value) => { + builder = builder.with_principal_status(value.clone()); + } + StatusSnapshot::Grant(_) => { + return Err(PyTypeError::new_err( + "principal_status must contain a principal snapshot", + )); + } + } + } + if let Some(snapshot) = grant_status { + match &snapshot.borrow(py).inner { + StatusSnapshot::Grant(value) => { + builder = builder.with_grant_status(value.clone()); + } + StatusSnapshot::Principal(_) => { + return Err(PyTypeError::new_err( + "grant_status must contain a grant snapshot", + )); + } + } + } + builder = + builder.with_channel_policy(ChannelBindingId::parse(channel_policy).map_err(value_error)?); + for identifier in evidence_types { + builder = builder.accept_evidence_type( + auths_model::EvidenceTypeId::parse(&identifier).map_err(value_error)?, + ); + } + for identifier in critical_extensions { + builder = builder.accept_critical_extension( + auths_model::ExtensionId::parse(&identifier).map_err(value_error)?, + ); + } + Ok(PyTrustedContext { + inner: builder.build().map_err(value_error)?, + }) +} + +#[pyfunction] +fn self_contained_configuration(py: Python<'_>) -> PyResult> { + Ok(PyBytes::new(py, &configuration()?)) +} + +#[pyfunction] +fn commit_plan_approval<'py>( + py: Python<'py>, + plan_commitment: &[u8], + configuration_digest: &[u8], + max_uses: u32, + expires_at: u64, +) -> PyResult> { + let plan = array32(plan_commitment, "plan commitment")?; + let configuration = array32(configuration_digest, "configuration digest")?; + let commitment = commit_plan_approval_native(&plan, &configuration, max_uses, expires_at) + .map_err(value_error)?; + Ok(PyBytes::new(py, commitment.as_bytes())) +} + +#[pyfunction] +fn inspect_unsigned<'py>( + py: Python<'py>, + value: PyRef<'_, PyUnsignedObject>, +) -> PyResult> { + let bytes = match &value.inner { + UnsignedObject::Grant(value) => auths_codec::encode_grant_statement(value), + UnsignedObject::Action(value) => auths_codec::encode_action_envelope(value), + UnsignedObject::PrincipalStatus(value) => { + auths_codec::encode_principal_status_statement(value) + } + UnsignedObject::GrantStatus(value) => auths_codec::encode_grant_status_statement(value), + } + .map_err(value_error)?; + Ok(PyBytes::new(py, &bytes)) +} + +#[pyfunction] +fn inspect_signed<'py>( + py: Python<'py>, + value: PyRef<'_, PySignedObject>, +) -> PyResult> { + let bytes = match &value.inner { + SignedObject::Grant(value) => auths_codec::encode_signed_grant(value), + SignedObject::Action(value) => auths_codec::encode_signed_action(value), + SignedObject::PrincipalStatus(value) => auths_codec::encode_signed_principal_status(value), + SignedObject::GrantStatus(value) => auths_codec::encode_signed_grant_status(value), + } + .map_err(value_error)?; + Ok(PyBytes::new(py, &bytes)) +} + +#[pyfunction] +fn inspect_plan<'py>( + py: Python<'py>, + value: PyRef<'_, PyAuthorizationPlan>, +) -> PyResult> { + let bytes = auths_codec::encode_authorization_plan(&value.inner).map_err(value_error)?; + Ok(PyBytes::new(py, &bytes)) +} + +#[pyfunction] +fn inspect_mcp_action<'py>( + py: Python<'py>, + value: PyRef<'_, PyMcpAction>, +) -> PyResult<(Bound<'py, PyBytes>, Bound<'py, PyBytes>)> { + Ok(( + PyBytes::new( + py, + &auths_codec::encode_canonical_action(&value.canonical).map_err(value_error)?, + ), + PyBytes::new(py, &value.arguments_json), + )) +} + +#[pyfunction] +fn inspect_trusted_context<'py>( + py: Python<'py>, + value: PyRef<'_, PyTrustedContext>, +) -> PyResult> { + let bytes = auths_codec::encode_verifier_context(&value.inner).map_err(value_error)?; + Ok(PyBytes::new(py, &bytes)) +} + +#[pyfunction] +fn parse_signed(kind: &str, value: &[u8]) -> PyResult { + let limits = VerifierLimits::default_deployment(); + let inner = match kind { + "grant" => SignedObject::Grant( + auths_codec::decode_signed_grant(value, &limits).map_err(value_error)?, + ), + "action" => SignedObject::Action( + auths_codec::decode_signed_action(value, &limits).map_err(value_error)?, + ), + "principal-status" => SignedObject::PrincipalStatus( + auths_codec::decode_signed_principal_status(value, &limits).map_err(value_error)?, + ), + "grant-status" => SignedObject::GrantStatus( + auths_codec::decode_signed_grant_status(value, &limits).map_err(value_error)?, + ), + _ => return Err(PyValueError::new_err("unsupported signed object kind")), + }; + Ok(PySignedObject { inner }) +} + +#[pyfunction] +fn parse_unsigned(kind: &str, value: &[u8]) -> PyResult { + let limits = VerifierLimits::default_deployment(); + let inner = match kind { + "grant" => UnsignedObject::Grant( + auths_codec::decode_grant_statement(value, &limits).map_err(value_error)?, + ), + "action" => UnsignedObject::Action( + auths_codec::decode_action_envelope(value, &limits).map_err(value_error)?, + ), + "principal-status" => UnsignedObject::PrincipalStatus( + auths_codec::decode_principal_status_statement(value, &limits).map_err(value_error)?, + ), + "grant-status" => UnsignedObject::GrantStatus( + auths_codec::decode_grant_status_statement(value, &limits).map_err(value_error)?, + ), + _ => return Err(PyValueError::new_err("unsupported unsigned object kind")), + }; + Ok(PyUnsignedObject { inner }) +} + +#[pyfunction] +fn parse_trusted_context(value: &[u8]) -> PyResult { + Ok(PyTrustedContext { + inner: auths_codec::decode_verifier_context(value).map_err(value_error)?, + }) +} + +#[pyfunction] +fn unsigned_from_signed(value: PyRef<'_, PySignedObject>) -> PyUnsignedObject { + let inner = match &value.inner { + SignedObject::Grant(value) => UnsignedObject::Grant(value.statement().clone()), + SignedObject::Action(value) => UnsignedObject::Action(value.envelope().clone()), + SignedObject::PrincipalStatus(value) => { + UnsignedObject::PrincipalStatus(value.statement().clone()) + } + SignedObject::GrantStatus(value) => UnsignedObject::GrantStatus(value.statement().clone()), + }; + PyUnsignedObject { inner } +} + +pub fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_function(wrap_pyfunction!(root_grant, module)?)?; + module.add_function(wrap_pyfunction!(plan_child, module)?)?; + module.add_function(wrap_pyfunction!(plan_child_statement, module)?)?; + module.add_function(wrap_pyfunction!(grant_request_from_statement, module)?)?; + module.add_function(wrap_pyfunction!(principal_status_statement, module)?)?; + module.add_function(wrap_pyfunction!(grant_status_statement, module)?)?; + module.add_function(wrap_pyfunction!(prepare_signing, module)?)?; + module.add_function(wrap_pyfunction!(prepare_mcp_action, module)?)?; + module.add_function(wrap_pyfunction!(status_snapshot, module)?)?; + module.add_function(wrap_pyfunction!(compile_trusted_context, module)?)?; + module.add_function(wrap_pyfunction!(self_contained_configuration, module)?)?; + module.add_function(wrap_pyfunction!(commit_plan_approval, module)?)?; + module.add_function(wrap_pyfunction!(inspect_unsigned, module)?)?; + module.add_function(wrap_pyfunction!(inspect_signed, module)?)?; + module.add_function(wrap_pyfunction!(inspect_plan, module)?)?; + module.add_function(wrap_pyfunction!(inspect_mcp_action, module)?)?; + module.add_function(wrap_pyfunction!(inspect_trusted_context, module)?)?; + module.add_function(wrap_pyfunction!(parse_signed, module)?)?; + module.add_function(wrap_pyfunction!(parse_unsigned, module)?)?; + module.add_function(wrap_pyfunction!(parse_trusted_context, module)?)?; + module.add_function(wrap_pyfunction!(unsigned_from_signed, module)?)?; + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn scope_parts( + subject: PrincipalId, + profile_id: &str, + profile_version: u16, + permissions: Vec<(String, String)>, + not_before: u64, + expires_at: u64, + audiences: Vec, + body_digests: Option>>, + budget: Option<(String, u64)>, + remaining_depth: u16, + status: Option<(String, u64)>, + assurance_floor: &str, + extensions: Vec<(String, Vec)>, +) -> PyResult { + Ok(ScopeParts { + subject, + profile: ProfileRef::new( + ProfileId::parse(profile_id).map_err(value_error)?, + profile_version, + ) + .map_err(value_error)?, + permissions: permission_set(permissions)?, + validity: ValidityWindow::new(Timestamp::new(not_before), Timestamp::new(expires_at)) + .map_err(value_error)?, + audiences: audience_set(audiences)?, + action_constraint: action_constraint(body_digests)?, + budget_ceiling: budget_ceiling(budget)?, + remaining_depth, + status_policy: status_policy(status)?, + assurance_floor: AssurancePolicyId::parse(assurance_floor).map_err(value_error)?, + extensions: critical_extensions(extensions)?, + }) +} + +fn scope_from_statement(statement: &auths_model::GrantStatement) -> ScopeParts { + ScopeParts { + subject: statement.subject().clone(), + profile: statement.profile().clone(), + permissions: statement.permissions().clone(), + validity: statement.validity(), + audiences: statement.audiences().clone(), + action_constraint: statement.action_constraint().clone(), + budget_ceiling: statement.budget_ceiling().cloned(), + remaining_depth: statement.remaining_depth(), + status_policy: statement.status_policy().clone(), + assurance_floor: statement.assurance_floor().clone(), + extensions: statement.extensions().clone(), + } +} + +fn permission_set(values: Vec<(String, String)>) -> PyResult { + PermissionSet::new( + values + .into_iter() + .map(|(capability, resource)| { + Ok(Permission::new( + auths_model::CapabilityId::parse(&capability).map_err(value_error)?, + ResourceId::parse(&resource).map_err(value_error)?, + )) + }) + .collect::>>()?, + ) + .map_err(value_error) +} + +fn audience_set(values: Vec) -> PyResult { + AudienceSet::new( + values + .into_iter() + .map(|value| Audience::parse(&value).map_err(value_error)) + .collect::>>()?, + ) + .map_err(value_error) +} + +fn action_constraint(values: Option>>) -> PyResult { + let Some(values) = values else { + return Ok(ActionConstraint::AnyBody); + }; + let digests = values + .into_iter() + .map(|value| array32(&value, "body digest").map(Digest::new)) + .collect::>>()?; + match digests.as_slice() { + [digest] => Ok(ActionConstraint::ExactBodyDigest(*digest)), + _ => ActionConstraint::allowed_body_digests(digests).map_err(value_error), + } +} + +fn budget_ceiling(value: Option<(String, u64)>) -> PyResult> { + value + .map(|(algebra, value)| { + Ok(BudgetCeiling::new( + BudgetAlgebraId::parse(&algebra).map_err(value_error)?, + value, + )) + }) + .transpose() +} + +fn status_policy(value: Option<(String, u64)>) -> PyResult { + match value { + Some((method, maximum_age)) => Ok(StatusPolicy::SnapshotRequired { + method: StatusMethodId::parse(&method).map_err(value_error)?, + max_age: FreshnessLimit::new(maximum_age).map_err(value_error)?, + }), + None => Ok(StatusPolicy::ExpiryOnly), + } +} + +fn critical_extensions(values: Vec<(String, Vec)>) -> PyResult { + CriticalExtensions::new( + values + .into_iter() + .map(|(identifier, bytes)| { + CriticalExtension::new( + auths_model::ExtensionId::parse(&identifier).map_err(value_error)?, + bytes, + ) + .map_err(value_error) + }) + .collect::>>()?, + ) + .map_err(value_error) +} + +fn principal_state(value: &str) -> PyResult { + match value { + "active" => Ok(PrincipalState::Active), + "revoked" => Ok(PrincipalState::Revoked), + "superseded" => Ok(PrincipalState::Superseded), + _ => Err(PyValueError::new_err("invalid principal status state")), + } +} + +fn grant_state(value: &str) -> PyResult { + match value { + "active" => Ok(GrantState::Active), + "revoked" => Ok(GrantState::Revoked), + "superseded" => Ok(GrantState::Superseded), + _ => Err(PyValueError::new_err("invalid grant status state")), + } +} + +pub(crate) fn signing_descriptor( + principal_method: &str, + verification_method: &str, + suite: &str, +) -> PyResult { + Ok(SignatureDescriptor::new( + PrincipalMethodId::parse(principal_method).map_err(value_error)?, + VerificationMethod::parse(verification_method).map_err(value_error)?, + SignatureSuiteId::parse(suite).map_err(value_error)?, + )) +} + +fn authority_diff(value: &AuthorityDiff) -> PyAuthorityDiff { + let (parent_depth, child_depth) = value.delegation_depth(); + PyAuthorityDiff { + removed_permissions: value.removed_permissions(), + removed_audiences: value.removed_audiences(), + validity_shortened: value.validity_shortened(), + action_narrowed: value.action_narrowed(), + budget_narrowed: value.budget_narrowed(), + status_narrowed: value.status_narrowed(), + parent_depth, + child_depth, + } +} + +const fn warning_label(value: OverGrantingWarning) -> &'static str { + match value { + OverGrantingWarning::AnyBody => "any-body", + OverGrantingWarning::MultiplePermissions => "multiple-permissions", + OverGrantingWarning::MultipleAudiences => "multiple-audiences", + OverGrantingWarning::DelegationAllowed => "delegation-allowed", + OverGrantingWarning::NoBudgetCeiling => "no-budget-ceiling", + OverGrantingWarning::LongValidity => "long-validity", + } +} + +fn build_plan( + py: Python<'_>, + members: Vec>, + operation: impl FnOnce( + &PlanBuilder<'_>, + Vec, + ) -> Result, +) -> PyResult { + let members = members + .iter() + .map(|member| member.borrow(py).inner.clone()) + .collect(); + let limits = VerifierLimits::default_deployment(); + Ok(PyAuthorizationPlan { + inner: operation(&PlanBuilder::new(&limits), members).map_err(value_error)?, + }) +} + +fn participant_role(value: &str) -> PyResult { + match value { + "root" => Ok(ParticipantRole::Root), + "intermediate" => Ok(ParticipantRole::Intermediate), + "actor" => Ok(ParticipantRole::Actor), + "external-issuer" => Ok(ParticipantRole::ExternalIssuer), + _ => Err(PyValueError::new_err("invalid assurance participant role")), + } +} + +fn assurance_quantifier(value: &str) -> PyResult { + match value { + "any" => Ok(AssuranceQuantifier::Any), + "every" => Ok(AssuranceQuantifier::Every), + _ => Err(PyValueError::new_err("invalid assurance quantifier")), + } +} + +pub(crate) fn configuration() -> PyResult<[u8; 32]> { + let raw_key = auths_raw_key::RawKeyMethod::new().map_err(value_error)?; + let did_key = auths_did_key::DidKeyMethod::new().map_err(value_error)?; + let did_keri = auths_did_keri::DidKeriMethod::new().map_err(value_error)?; + let ed25519 = auths_signature::Ed25519Suite::new().map_err(value_error)?; + let p256 = auths_signature::P256Sha256Suite::new().map_err(value_error)?; + let methods: [&dyn PrincipalMethod; 3] = [&raw_key, &did_key, &did_keri]; + let suites: [&dyn SignatureSuite; 2] = [&ed25519, &p256]; + let registries = + auths_registries::ImmutableRegistries::new(&methods, &suites).map_err(value_error)?; + Ok(*registries.configuration_id().as_bytes()) +} + +fn array32(value: &[u8], label: &str) -> PyResult<[u8; 32]> { + value + .try_into() + .map_err(|_| PyValueError::new_err(format!("{label} must contain 32 bytes"))) +} + +pub(crate) fn value_error(error: impl std::fmt::Display) -> PyErr { + PyValueError::new_err(error.to_string()) +} diff --git a/bindings/python/src/http.rs b/bindings/python/src/http.rs new file mode 100644 index 00000000..595f9bf9 --- /dev/null +++ b/bindings/python/src/http.rs @@ -0,0 +1,719 @@ +#![allow(clippy::needless_pass_by_value, clippy::too_many_arguments)] + +use crate::ReviewProjection; +use crate::authoring::{ + PyPrincipal, PySignedObject, PyTrustedContext, PyUnsignedObject, SignedObject, UnsignedObject, + value_error, +}; +use crate::result::{NativeVerificationResult, native_result, verify_sealed}; +use auths_author::{ + ProfilePlanCommitment, ProfilePlanMember, WorkflowProofBuilder, address_evidence, + prepare_profile_action, +}; +use auths_model::{Audience, EvidenceTypeId, MediaType, ResourceId}; +use auths_profile_api::ActionProfile; +use auths_profile_domains::{HttpAction, HttpCommand, HttpProfile}; +use pyo3::{ + exceptions::{PyRuntimeError, PyTypeError, PyValueError}, + prelude::*, + types::PyBytes, +}; +use std::collections::{BTreeMap, HashSet}; + +const PROFILE_ID: &str = "auths.http"; +const PROFILE_VERSION: u16 = 1; + +#[pyclass( + name = "HttpCall", + frozen, + module = "auths._native", + skip_from_py_object +)] +pub struct PyHttpCall { + inner: HttpAction, +} + +#[pymethods] +impl PyHttpCall { + #[getter] + fn method(&self) -> &str { + self.inner.method() + } + + #[getter] + fn scheme(&self) -> &str { + self.inner.scheme() + } + + #[getter] + fn authority(&self) -> &str { + self.inner.authority() + } + + #[getter] + fn path(&self) -> &str { + self.inner.path() + } +} + +#[pyclass( + name = "HttpAction", + frozen, + module = "auths._native", + skip_from_py_object +)] +pub struct PyHttpPreparedAction { + canonical: auths_model::CanonicalAction, + envelope: auths_model::ActionEnvelope, + audience: String, + review_title: String, + review_fields: Vec<(String, String)>, +} + +#[pymethods] +impl PyHttpPreparedAction { + #[getter] + fn unsigned(&self) -> PyUnsignedObject { + PyUnsignedObject { + inner: UnsignedObject::Action(self.envelope.clone()), + } + } + + #[getter] + fn audience(&self) -> &str { + &self.audience + } + + #[getter] + fn review_title(&self) -> &str { + &self.review_title + } + + #[getter] + fn review_fields(&self) -> Vec<(String, String)> { + self.review_fields.clone() + } +} + +#[pyclass( + name = "NativeHttpPlan", + frozen, + module = "auths._native", + skip_from_py_object +)] +pub struct PyNativeHttpPlan { + commitment: [u8; 32], + members: Vec<[u8; 32]>, + permissions: Vec<(String, String)>, + resource_namespaces: Vec, + audiences: Vec, +} + +#[pymethods] +impl PyNativeHttpPlan { + #[getter] + fn commitment<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.commitment) + } + + #[getter] + fn members(&self) -> Vec> { + self.members.iter().map(|value| value.to_vec()).collect() + } + + #[getter] + fn permissions(&self) -> Vec<(String, String)> { + self.permissions.clone() + } + + #[getter] + fn resource_namespaces(&self) -> Vec { + self.resource_namespaces.clone() + } + + #[getter] + fn audiences(&self) -> Vec { + self.audiences.clone() + } +} + +#[pyclass(name = "HttpCommand", module = "auths._native", skip_from_py_object)] +pub struct PyHttpCommand { + inner: Option, + authority_commitment: [u8; 32], + context_commitment: [u8; 32], +} + +#[pymethods] +#[allow(clippy::unused_self)] +impl PyHttpCommand { + #[getter] + fn action_commitment<'py>(&self, py: Python<'py>) -> PyResult> { + Ok(PyBytes::new(py, &self.action_commitment_bytes()?)) + } + + #[getter] + fn authority_commitment<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.authority_commitment) + } + + #[getter] + fn context_commitment<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.context_commitment) + } + + fn __repr__(&self) -> &'static str { + if self.inner.is_some() { + "HttpCommand()" + } else { + "HttpCommand()" + } + } + + fn __copy__(&self) -> PyResult<()> { + Err(command_error()) + } + + fn __deepcopy__(&self, _memo: &Bound<'_, PyAny>) -> PyResult<()> { + Err(command_error()) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(command_error()) + } + + fn __reduce_ex__(&self, _protocol: i32) -> PyResult<()> { + Err(command_error()) + } +} + +impl PyHttpCommand { + fn command(&self) -> PyResult<&HttpCommand> { + self.inner + .as_ref() + .ok_or_else(|| PyRuntimeError::new_err("HTTP command has already been consumed")) + } + + fn action_commitment_bytes(&self) -> PyResult<[u8; 32]> { + canonical_http_commitment(self.command()?.action()) + } +} + +#[pyclass( + name = "HttpPlanCommand", + module = "auths._native", + skip_from_py_object +)] +pub struct PyHttpPlanCommand { + commands: Option>, + commitment: [u8; 32], + receipt_bindings: Vec<([u8; 32], [u8; 32], [u8; 32])>, +} + +#[pymethods] +#[allow(clippy::unused_self)] +impl PyHttpPlanCommand { + #[getter] + fn count(&self) -> PyResult { + Ok(self.commands()?.len()) + } + + #[getter] + fn plan_commitment<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.commitment) + } + + #[getter] + fn receipt_bindings(&self) -> Vec<(Vec, Vec, Vec)> { + self.receipt_bindings + .iter() + .map(|(action, authority, context)| { + (action.to_vec(), authority.to_vec(), context.to_vec()) + }) + .collect() + } + + fn __repr__(&self) -> &'static str { + if self.commands.is_some() { + "HttpPlanCommand()" + } else { + "HttpPlanCommand()" + } + } + + fn __copy__(&self) -> PyResult<()> { + Err(plan_command_error()) + } + + fn __deepcopy__(&self, _memo: &Bound<'_, PyAny>) -> PyResult<()> { + Err(plan_command_error()) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(plan_command_error()) + } +} + +impl PyHttpPlanCommand { + fn commands(&self) -> PyResult<&[HttpCommand]> { + self.commands + .as_deref() + .ok_or_else(|| PyRuntimeError::new_err("HTTP plan command has already been consumed")) + } +} + +#[pyclass(name = "HttpGatewayRequest", frozen, module = "auths._native")] +pub struct PyHttpGatewayRequest { + method: String, + scheme: String, + authority: String, + path: String, + query: Vec<(String, Vec)>, + headers: Vec<(String, String)>, + content_type: Option, + body_digest: Option, +} + +#[pymethods] +impl PyHttpGatewayRequest { + #[getter] + fn method(&self) -> &str { + &self.method + } + #[getter] + fn scheme(&self) -> &str { + &self.scheme + } + #[getter] + fn authority(&self) -> &str { + &self.authority + } + #[getter] + fn path(&self) -> &str { + &self.path + } + #[getter] + fn query(&self) -> Vec<(String, Vec)> { + self.query.clone() + } + #[getter] + fn headers(&self) -> Vec<(String, String)> { + self.headers.clone() + } + #[getter] + fn content_type(&self) -> Option<&str> { + self.content_type.as_deref() + } + #[getter] + fn body_digest(&self) -> Option<&str> { + self.body_digest.as_deref() + } +} + +#[pyfunction] +fn http_call( + method: String, + scheme: String, + authority: String, + path: String, + query: Vec<(String, Vec)>, + headers: Vec<(String, String)>, + content_type: Option, + body_digest: Option, +) -> PyResult { + let call = HttpAction::new( + method, + scheme, + authority, + path, + query.into_iter().collect::>(), + headers.into_iter().collect::>(), + content_type, + body_digest, + ); + canonical_http(&call)?; + Ok(PyHttpCall { inner: call }) +} + +#[pyfunction] +fn review_http_call<'py>( + py: Python<'py>, + call: PyRef<'_, PyHttpCall>, +) -> PyResult> { + let canonical = canonical_http(&call.inner)?; + let display = HttpProfile::default() + .review_display(&canonical) + .map_err(value_error)?; + let commitment = canonical_http_commitment(&call.inner)?; + Ok(( + display.title().to_owned(), + display.fields().to_vec(), + PyBytes::new(py, &commitment), + )) +} + +#[pyfunction] +fn commit_http_plan(calls: Vec>, py: Python<'_>) -> PyResult { + if calls.is_empty() || calls.len() > 256 { + return Err(PyValueError::new_err( + "HTTP plan action count is outside native limits", + )); + } + let calls = calls + .iter() + .map(|call| call.borrow(py).inner.clone()) + .collect::>(); + let origin_value = origin(calls.first().expect("non-empty")); + if calls.iter().any(|call| origin(call) != origin_value) { + return Err(PyValueError::new_err( + "HTTP plan actions must share one origin", + )); + } + let members = calls + .iter() + .map(canonical_plan_member) + .collect::>>()?; + let borrowed = members.iter().map(Vec::as_slice).collect::>(); + let commitment = ProfilePlanCommitment::commit(PROFILE_ID, PROFILE_VERSION, &borrowed) + .map_err(value_error)?; + let permissions = calls + .iter() + .map(|call| { + let canonical = canonical_http(call)?; + Ok(( + canonical.permission().capability().as_str().to_owned(), + canonical.permission().resource().as_str().to_owned(), + )) + }) + .collect::>>()?; + Ok(PyNativeHttpPlan { + commitment: *commitment.plan().as_bytes(), + members: commitment + .members() + .iter() + .map(|value| *value.as_bytes()) + .collect(), + permissions, + resource_namespaces: vec![origin_value.clone()], + audiences: vec![origin_value], + }) +} + +#[pyfunction] +fn prepare_http_action( + call: PyRef<'_, PyHttpCall>, + actor: PyRef<'_, PyPrincipal>, + terminal_grant: PyRef<'_, PySignedObject>, + challenge: &[u8], + evaluation_time: u64, +) -> PyResult { + let SignedObject::Grant(terminal_grant) = &terminal_grant.inner else { + return Err(PyTypeError::new_err( + "terminal grant must be a signed grant", + )); + }; + let canonical = canonical_http(&call.inner)?; + let display = HttpProfile::default() + .review_display(&canonical) + .map_err(value_error)?; + let audience = Audience::parse(&origin(&call.inner)).map_err(value_error)?; + let challenge: [u8; 32] = challenge + .try_into() + .map_err(|_| PyValueError::new_err("challenge must contain 32 bytes"))?; + let prepared = prepare_profile_action( + canonical, + audience.clone(), + actor.inner.clone(), + terminal_grant, + challenge, + evaluation_time, + ) + .map_err(value_error)?; + let (canonical, envelope) = prepared.into_parts(); + Ok(PyHttpPreparedAction { + canonical, + envelope, + audience: audience.to_string(), + review_title: display.title().to_owned(), + review_fields: display.fields().to_vec(), + }) +} + +#[pyfunction] +fn authorize_http( + py: Python<'_>, + prepared: PyRef<'_, PyHttpPreparedAction>, + signed_action: PyRef<'_, PySignedObject>, + grants: Vec>, + grant_evidence: Vec)>>, + action_evidence: Vec<(String, String, Vec)>, + context: PyRef<'_, PyTrustedContext>, +) -> PyResult<(NativeVerificationResult, Option)> { + if grants.len() != grant_evidence.len() { + return Err(PyValueError::new_err( + "each grant requires one evidence collection", + )); + } + let SignedObject::Action(action) = &signed_action.inner else { + return Err(PyTypeError::new_err("signed action must be an action")); + }; + if action.envelope() != &prepared.envelope { + return Err(PyValueError::new_err( + "signed action does not match its native preparation", + )); + } + let mut builder = WorkflowProofBuilder::new(); + for (grant, evidence) in grants.iter().zip(grant_evidence) { + let grant = grant.borrow(py); + let SignedObject::Grant(grant) = &grant.inner else { + return Err(PyTypeError::new_err("grant chain contains a non-grant")); + }; + let index = builder.push_grant(grant.clone()).map_err(value_error)?; + for (evidence_type, media_type, bytes) in evidence { + builder + .bind_grant_evidence(index, evidence_object(&evidence_type, &media_type, bytes)?) + .map_err(value_error)?; + } + } + for (evidence_type, media_type, bytes) in action_evidence { + builder + .bind_action_evidence(evidence_object(&evidence_type, &media_type, bytes)?) + .map_err(value_error)?; + } + let artifacts = builder + .finish(action, &prepared.canonical, &context.inner) + .map_err(value_error)?; + let proof = auths_codec::encode_bundle(artifacts.proof()).map_err(value_error)?; + let canonical = + auths_codec::encode_canonical_action(&prepared.canonical).map_err(value_error)?; + let context = auths_codec::encode_verifier_context(artifacts.context()).map_err(value_error)?; + let authority_commitment = *auths_codec::proof_digest(artifacts.proof()) + .map_err(value_error)? + .as_bytes(); + let context_commitment = *auths_codec::context_digest(artifacts.context()) + .map_err(value_error)? + .as_bytes(); + let sealed = verify_sealed(&proof, &canonical, &context)?; + let command = sealed + .action() + .map(|action| HttpProfile::default().decode_verified(action)) + .transpose() + .map_err(value_error)? + .map(|inner| PyHttpCommand { + inner: Some(inner), + authority_commitment, + context_commitment, + }); + Ok((native_result(py, sealed)?, command)) +} + +#[pyfunction] +fn inspect_http_action<'py>( + py: Python<'py>, + action: PyRef<'_, PyHttpPreparedAction>, +) -> PyResult> { + let bytes = auths_codec::encode_canonical_action(&action.canonical).map_err(value_error)?; + Ok(PyBytes::new(py, &bytes)) +} + +#[pyfunction] +fn consume_http_command( + mut command: PyRefMut<'_, PyHttpCommand>, + expected_origin: &str, +) -> PyResult { + if origin(command.command()?.action()) != expected_origin { + return Err(PyTypeError::new_err( + "HTTP command does not belong to this gateway", + )); + } + let command = command + .inner + .take() + .ok_or_else(|| PyRuntimeError::new_err("HTTP command has already been consumed"))?; + Ok(gateway_request(command.action())) +} + +#[pyfunction] +fn seal_http_plan_command( + py: Python<'_>, + commands: Vec>, + expected_origin: &str, + expected_commitment: &[u8], +) -> PyResult { + if commands.is_empty() || commands.len() > 256 { + return Err(PyValueError::new_err( + "HTTP plan command count is outside native limits", + )); + } + let expected: [u8; 32] = expected_commitment + .try_into() + .map_err(|_| PyValueError::new_err("plan commitment must contain 32 bytes"))?; + let mut identities = HashSet::with_capacity(commands.len()); + if commands + .iter() + .any(|command| !identities.insert(command.as_ptr() as usize)) + { + return Err(PyValueError::new_err( + "HTTP plan contains a duplicate command handle", + )); + } + let members = commands + .iter() + .map(|command| { + let command = command.borrow(py); + if origin(command.command()?.action()) != expected_origin { + return Err(PyTypeError::new_err( + "HTTP command does not belong to this plan", + )); + } + canonical_plan_member(command.command()?.action()) + }) + .collect::>>()?; + let borrowed = members.iter().map(Vec::as_slice).collect::>(); + let commitment = ProfilePlanCommitment::commit(PROFILE_ID, PROFILE_VERSION, &borrowed) + .map_err(value_error)?; + if commitment.plan().as_bytes() != &expected { + return Err(PyValueError::new_err( + "verified commands do not match the exact HTTP plan", + )); + } + let receipt_bindings = commands + .iter() + .map(|command| { + let command = command.borrow(py); + Ok(( + command.action_commitment_bytes()?, + command.authority_commitment, + command.context_commitment, + )) + }) + .collect::>>()?; + let inner = + commands + .iter() + .map(|command| { + command.borrow_mut(py).inner.take().ok_or_else(|| { + PyRuntimeError::new_err("HTTP command has already been consumed") + }) + }) + .collect::>>()?; + Ok(PyHttpPlanCommand { + commands: Some(inner), + commitment: expected, + receipt_bindings, + }) +} + +#[pyfunction] +fn consume_http_plan_command( + mut command: PyRefMut<'_, PyHttpPlanCommand>, + expected_origin: &str, +) -> PyResult> { + if command + .commands()? + .iter() + .any(|value| origin(value.action()) != expected_origin) + { + return Err(PyTypeError::new_err( + "HTTP plan command does not belong to this gateway", + )); + } + command + .commands + .take() + .ok_or_else(|| PyRuntimeError::new_err("HTTP plan command has already been consumed"))? + .iter() + .map(|value| Ok(gateway_request(value.action()))) + .collect() +} + +fn canonical_http(action: &HttpAction) -> PyResult { + let bytes = serde_json_canonicalizer::to_vec(action).map_err(value_error)?; + HttpProfile::default() + .canonicalize(&bytes) + .map_err(value_error) +} + +fn canonical_http_commitment(action: &HttpAction) -> PyResult<[u8; 32]> { + let encoded = + auths_codec::encode_canonical_action(&canonical_http(action)?).map_err(value_error)?; + Ok( + *auths_codec::domain_commitment("auths.canonical-action.v1", &encoded) + .map_err(value_error)? + .as_bytes(), + ) +} + +fn canonical_plan_member(action: &HttpAction) -> PyResult> { + ProfilePlanMember::encode( + &canonical_http(action)?, + &ResourceId::parse(&origin(action)).map_err(value_error)?, + &Audience::parse(&origin(action)).map_err(value_error)?, + ) + .map_err(value_error) +} + +fn origin(action: &HttpAction) -> String { + format!("{}://{}", action.scheme(), action.authority()) +} + +fn gateway_request(action: &HttpAction) -> PyHttpGatewayRequest { + PyHttpGatewayRequest { + method: action.method().to_owned(), + scheme: action.scheme().to_owned(), + authority: action.authority().to_owned(), + path: action.path().to_owned(), + query: action + .query() + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + headers: action + .headers() + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(), + content_type: action.content_type().map(str::to_owned), + body_digest: action.body_digest().map(str::to_owned), + } +} + +fn evidence_object( + evidence_type: &str, + media_type: &str, + bytes: Vec, +) -> PyResult { + address_evidence( + EvidenceTypeId::parse(evidence_type).map_err(value_error)?, + MediaType::parse(media_type).map_err(value_error)?, + bytes, + ) + .map_err(value_error) +} + +fn command_error() -> PyErr { + PyTypeError::new_err("HttpCommand is a non-copyable native capability") +} + +fn plan_command_error() -> PyErr { + PyTypeError::new_err("HttpPlanCommand is a non-copyable native capability") +} + +pub fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_function(wrap_pyfunction!(http_call, module)?)?; + module.add_function(wrap_pyfunction!(review_http_call, module)?)?; + module.add_function(wrap_pyfunction!(commit_http_plan, module)?)?; + module.add_function(wrap_pyfunction!(prepare_http_action, module)?)?; + module.add_function(wrap_pyfunction!(authorize_http, module)?)?; + module.add_function(wrap_pyfunction!(inspect_http_action, module)?)?; + module.add_function(wrap_pyfunction!(consume_http_command, module)?)?; + module.add_function(wrap_pyfunction!(seal_http_plan_command, module)?)?; + module.add_function(wrap_pyfunction!(consume_http_plan_command, module)?)?; + Ok(()) +} diff --git a/bindings/python/src/identity.rs b/bindings/python/src/identity.rs new file mode 100644 index 00000000..b3279fe3 --- /dev/null +++ b/bindings/python/src/identity.rs @@ -0,0 +1,315 @@ +use auths_identity::{ + IdentityDescriptor, IdentityPacket, PublicIdentity, SignatureVerifier, SignedIdentityMessage, + VerificationMaterial, VerificationRelationship, +}; +use auths_identity_raw_key::RawKeyIdentityMethod; +use auths_signature_ed25519::Ed25519Verifier; +use pyo3::{exceptions::PyValueError, prelude::*, types::PyBytes}; + +#[pyclass(name = "IdentityProjection", frozen, module = "auths._native")] +pub struct PyIdentityProjection { + method_id: String, + identity_id: String, + suite_id: String, + public_key: Vec, + packet_kind: &'static str, + message: Option>, + signature: Option>, +} + +type RelationshipProjection = (String, String, String, Vec<(String, Vec)>); + +#[pyclass( + name = "IdentityDescriptorProjection", + frozen, + module = "auths._native" +)] +pub struct PyIdentityDescriptorProjection { + method_id: String, + identity_id: String, + method_material: Vec, + relationships: Vec, +} + +#[pymethods] +impl PyIdentityDescriptorProjection { + #[getter] + fn method_id(&self) -> &str { + &self.method_id + } + + #[getter] + fn identity_id(&self) -> &str { + &self.identity_id + } + + #[getter] + fn method_material<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.method_material) + } + + #[getter] + fn relationships(&self) -> Vec { + self.relationships.clone() + } +} + +#[pymethods] +impl PyIdentityProjection { + #[getter] + fn method_id(&self) -> &str { + &self.method_id + } + + #[getter] + fn identity_id(&self) -> &str { + &self.identity_id + } + + #[getter] + fn suite_id(&self) -> &str { + &self.suite_id + } + + #[getter] + fn public_key<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.public_key) + } + + #[getter] + fn packet_kind(&self) -> &'static str { + self.packet_kind + } + + #[getter] + fn message<'py>(&self, py: Python<'py>) -> Option> { + self.message.as_ref().map(|value| PyBytes::new(py, value)) + } + + #[getter] + fn signature<'py>(&self, py: Python<'py>) -> Option> { + self.signature.as_ref().map(|value| PyBytes::new(py, value)) + } +} + +#[pyfunction] +fn decode_identity_v1(packet: &[u8]) -> PyResult { + let packet = IdentityPacket::decode(packet).map_err(value_error)?; + Ok(projection(&packet)) +} + +#[pyfunction] +fn encode_identity_descriptor_v1<'py>( + py: Python<'py>, + method_id: &str, + identity_id: &str, + method_material: &[u8], + relationships: Vec, +) -> PyResult> { + let relationships = relationships + .into_iter() + .map(|(relationship_id, purpose, suite_id, materials)| { + VerificationRelationship::new( + &relationship_id, + &purpose, + &suite_id, + materials + .into_iter() + .map(|(material_id, bytes)| VerificationMaterial::new(&material_id, bytes)) + .collect::, _>>()?, + ) + }) + .collect::, _>>() + .map_err(value_error)?; + let descriptor = IdentityDescriptor::new( + method_id, + identity_id, + method_material.to_vec(), + relationships, + ) + .map_err(value_error)?; + let encoded = descriptor.encode().map_err(value_error)?; + Ok(PyBytes::new(py, &encoded)) +} + +#[pyfunction] +fn decode_identity_descriptor_v1(packet: &[u8]) -> PyResult { + let descriptor = IdentityDescriptor::decode(packet).map_err(value_error)?; + Ok(descriptor_projection(&descriptor)) +} + +#[pyfunction] +fn compact_identity_descriptor_v1<'py>( + py: Python<'py>, + packet: &[u8], +) -> PyResult> { + let identity = match IdentityPacket::decode(packet).map_err(value_error)? { + IdentityPacket::PublicIdentity(value) => value, + IdentityPacket::SignedMessage(_) => { + return Err(PyValueError::new_err("expected a public identity packet")); + } + }; + let encoded = identity + .to_descriptor() + .and_then(|descriptor| descriptor.encode()) + .map_err(value_error)?; + Ok(PyBytes::new(py, &encoded)) +} + +#[pyfunction] +fn identity_descriptor_signing_preimage_v1<'py>( + py: Python<'py>, + packet: &[u8], + relationship_id: &str, + message: &[u8], +) -> PyResult> { + let descriptor = IdentityDescriptor::decode(packet).map_err(value_error)?; + let preimage = descriptor + .signing_preimage(relationship_id, message) + .map_err(value_error)?; + Ok(PyBytes::new(py, &preimage)) +} + +#[pyfunction] +fn encode_public_identity_v1<'py>( + py: Python<'py>, + method_id: &str, + identity_id: &str, + suite_id: &str, + public_key: &[u8], +) -> PyResult> { + let identity = PublicIdentity::new(method_id, identity_id, suite_id, public_key.to_vec()) + .map_err(value_error)?; + let packet = IdentityPacket::PublicIdentity(identity) + .encode() + .map_err(value_error)?; + Ok(PyBytes::new(py, &packet)) +} + +#[pyfunction] +fn raw_key_identity_v2<'py>( + py: Python<'py>, + suite_id: &str, + public_key: &[u8], +) -> PyResult> { + let identity = RawKeyIdentityMethod::identity(suite_id, public_key.to_vec()) + .map_err(value_error)? + .into_public_identity(); + let packet = IdentityPacket::PublicIdentity(identity) + .encode() + .map_err(value_error)?; + Ok(PyBytes::new(py, &packet)) +} + +#[pyfunction] +fn validate_raw_key_identity_v2( + method_id: &str, + identity_id: &str, + suite_id: &str, + public_key: &[u8], +) -> PyResult<()> { + PublicIdentity::new(method_id, identity_id, suite_id, public_key.to_vec()) + .and_then(|identity| identity.validate(&RawKeyIdentityMethod)) + .map_err(value_error)?; + Ok(()) +} + +#[pyfunction] +fn identity_signing_preimage_v1<'py>( + py: Python<'py>, + method_id: &str, + identity_id: &str, + suite_id: &str, + public_key: &[u8], + message: &[u8], +) -> PyResult> { + let identity = PublicIdentity::new(method_id, identity_id, suite_id, public_key.to_vec()) + .map_err(value_error)?; + let preimage = + SignedIdentityMessage::signing_preimage(&identity, message).map_err(value_error)?; + Ok(PyBytes::new(py, &preimage)) +} + +#[pyfunction] +fn verify_ed25519_preimage_v1( + py: Python<'_>, + public_key: Vec, + preimage: Vec, + signature: Vec, +) -> PyResult<()> { + py.detach(move || { + Ed25519Verifier + .verify(&public_key, &preimage, &signature) + .map_err(value_error) + }) +} + +fn projection(packet: &IdentityPacket) -> PyIdentityProjection { + let identity = packet.identity(); + let (packet_kind, message, signature) = match &packet { + IdentityPacket::PublicIdentity(_) => ("public-identity", None, None), + IdentityPacket::SignedMessage(signed) => ( + "signed-message", + Some(signed.message().to_vec()), + Some(signed.signature().to_vec()), + ), + }; + PyIdentityProjection { + method_id: identity.method_id().to_owned(), + identity_id: identity.identity_id().to_owned(), + suite_id: identity.suite_id().to_owned(), + public_key: identity.public_key().to_vec(), + packet_kind, + message, + signature, + } +} + +fn descriptor_projection(descriptor: &IdentityDescriptor) -> PyIdentityDescriptorProjection { + PyIdentityDescriptorProjection { + method_id: descriptor.method_id().to_owned(), + identity_id: descriptor.identity_id().to_owned(), + method_material: descriptor.method_material().to_vec(), + relationships: descriptor + .relationships() + .iter() + .map(|relationship| { + ( + relationship.relationship_id().to_owned(), + relationship.purpose().to_owned(), + relationship.suite_id().to_owned(), + relationship + .verification_material() + .iter() + .map(|material| { + (material.material_id().to_owned(), material.bytes().to_vec()) + }) + .collect(), + ) + }) + .collect(), + } +} + +pub fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_function(wrap_pyfunction!(decode_identity_v1, module)?)?; + module.add_function(wrap_pyfunction!(encode_identity_descriptor_v1, module)?)?; + module.add_function(wrap_pyfunction!(decode_identity_descriptor_v1, module)?)?; + module.add_function(wrap_pyfunction!(compact_identity_descriptor_v1, module)?)?; + module.add_function(wrap_pyfunction!( + identity_descriptor_signing_preimage_v1, + module + )?)?; + module.add_function(wrap_pyfunction!(encode_public_identity_v1, module)?)?; + module.add_function(wrap_pyfunction!(raw_key_identity_v2, module)?)?; + module.add_function(wrap_pyfunction!(validate_raw_key_identity_v2, module)?)?; + module.add_function(wrap_pyfunction!(identity_signing_preimage_v1, module)?)?; + module.add_function(wrap_pyfunction!(verify_ed25519_preimage_v1, module)?)?; + Ok(()) +} + +fn value_error(error: impl std::fmt::Display) -> PyErr { + PyValueError::new_err(error.to_string()) +} diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index f3db77a1..51a6ce28 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -1,29 +1,40 @@ -//! Python extension for the bounded three-input Auths V1 engine. +//! Native Python boundary for Auths protocol semantics. #![forbid(unsafe_code)] -use pyo3::{exceptions::PyRuntimeError, prelude::*, types::PyBytes}; +mod application; +mod authoring; +mod http; +mod identity; +mod mcp; +mod result; +mod runtime; +mod workflow; + +use pyo3::prelude::*; +use pyo3::types::PyBytes; + +type ReviewProjection<'py> = (String, Vec<(String, String)>, Bound<'py, PyBytes>); -/// Executes the self-contained V1 verifier and returns canonical result CBOR. #[pyfunction] -fn verify_v1<'py>( - py: Python<'py>, - proof_cbor: &[u8], - canonical_action_cbor: &[u8], - trusted_context_cbor: &[u8], -) -> PyResult> { - let result = auths_proof_wasm::verify_self_contained_v1( - proof_cbor, - canonical_action_cbor, - trusted_context_cbor, - ) - .map_err(|error| PyRuntimeError::new_err(error.to_string()))?; - Ok(PyBytes::new(py, &result)) +fn generate_challenge_v1(py: Python<'_>) -> PyResult> { + let mut challenge = [0_u8; 32]; + getrandom::fill(&mut challenge) + .map_err(|_| pyo3::exceptions::PyRuntimeError::new_err("secure randomness unavailable"))?; + Ok(PyBytes::new(py, &challenge)) } /// Installs the private native extension consumed by `auths`. #[pymodule] fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { - module.add_function(wrap_pyfunction!(verify_v1, module)?)?; + module.add_function(wrap_pyfunction!(generate_challenge_v1, module)?)?; + authoring::register(module)?; + application::register(module)?; + identity::register(module)?; + http::register(module)?; + mcp::register(module)?; + result::register(module)?; + runtime::register(module)?; + workflow::register(module)?; Ok(()) } diff --git a/bindings/python/src/mcp.rs b/bindings/python/src/mcp.rs new file mode 100644 index 00000000..ae18907f --- /dev/null +++ b/bindings/python/src/mcp.rs @@ -0,0 +1,652 @@ +#![allow( + clippy::needless_pass_by_value, + clippy::too_many_arguments, + clippy::unused_self +)] + +use crate::ReviewProjection; +use crate::authoring::{ + PyMcpAction, PyPrincipal, PySignedObject, PyTrustedContext, SignedObject, value_error, +}; +use crate::result::{NativeVerificationResult, native_result, verify_sealed}; +use auths_author::{ + ProfilePlanCommitment, ProfilePlanMember, WorkflowProofBuilder, address_evidence, + prepare_profile_action, +}; +use auths_model::{EvidenceTypeId, MediaType, ResourceId}; +use auths_profile_api::ActionProfile; +use auths_profile_mcp::{ + MAX_CANONICAL_CALL_BYTES, McpCommand, McpProfile, McpToolCall, PROFILE_ID, PROFILE_VERSION, +}; +use pyo3::{ + exceptions::{PyRuntimeError, PyTypeError, PyValueError}, + prelude::*, + types::PyBytes, +}; +use serde_json::{Map, Value}; + +#[pyclass( + name = "McpCall", + frozen, + module = "auths._native", + skip_from_py_object +)] +pub struct PyMcpCall { + inner: McpToolCall, +} + +#[pymethods] +impl PyMcpCall { + #[getter] + fn service(&self) -> &str { + self.inner.service() + } + + #[getter] + fn name(&self) -> &str { + self.inner.name() + } + + fn __repr__(&self) -> String { + format!( + "McpCall(service={:?}, name={:?})", + self.inner.service(), + self.inner.name() + ) + } +} + +#[pyclass( + name = "NativeMcpPlan", + frozen, + module = "auths._native", + skip_from_py_object +)] +pub struct PyNativeMcpPlan { + commitment: [u8; 32], + members: Vec<[u8; 32]>, + permissions: Vec<(String, String)>, + resource_namespaces: Vec, + audiences: Vec, +} + +#[pymethods] +impl PyNativeMcpPlan { + #[getter] + fn commitment<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.commitment) + } + + #[getter] + fn members(&self) -> Vec> { + self.members.iter().map(|member| member.to_vec()).collect() + } + + #[getter] + fn permissions(&self) -> Vec<(String, String)> { + self.permissions.clone() + } + + #[getter] + fn resource_namespaces(&self) -> Vec { + self.resource_namespaces.clone() + } + + #[getter] + fn audiences(&self) -> Vec { + self.audiences.clone() + } +} + +#[pyclass(name = "McpCommand", module = "auths._native", skip_from_py_object)] +pub struct PyMcpCommand { + inner: Option, + authority_commitment: [u8; 32], + context_commitment: [u8; 32], +} + +#[pymethods] +impl PyMcpCommand { + #[getter] + fn action_commitment<'py>(&self, py: Python<'py>) -> PyResult> { + Ok(PyBytes::new(py, &self.action_commitment_bytes()?)) + } + + #[getter] + fn authority_commitment<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.authority_commitment) + } + + #[getter] + fn context_commitment<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.context_commitment) + } + + #[getter] + fn service(&self) -> PyResult<&str> { + Ok(self.command()?.call().service()) + } + + #[getter] + fn name(&self) -> PyResult<&str> { + Ok(self.command()?.name()) + } + + fn __repr__(&self) -> &'static str { + if self.inner.is_some() { + "McpCommand()" + } else { + "McpCommand()" + } + } + + fn __copy__(&self) -> PyResult<()> { + Err(command_error()) + } + + fn __deepcopy__(&self, _memo: &Bound<'_, PyAny>) -> PyResult<()> { + Err(command_error()) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(command_error()) + } + + fn __reduce_ex__(&self, _protocol: i32) -> PyResult<()> { + Err(command_error()) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(command_error()) + } +} + +#[pyclass(name = "McpPlanCommand", module = "auths._native", skip_from_py_object)] +pub struct PyMcpPlanCommand { + commands: Option>, + commitment: [u8; 32], + receipt_bindings: Vec<([u8; 32], [u8; 32], [u8; 32])>, +} + +#[pymethods] +impl PyMcpPlanCommand { + #[getter] + fn count(&self) -> PyResult { + Ok(self.commands()?.len()) + } + + #[getter] + fn plan_commitment<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.commitment) + } + + #[getter] + fn receipt_bindings(&self) -> Vec<(Vec, Vec, Vec)> { + self.receipt_bindings + .iter() + .map(|(action, authority, context)| { + (action.to_vec(), authority.to_vec(), context.to_vec()) + }) + .collect() + } + + fn __repr__(&self) -> &'static str { + if self.commands.is_some() { + "McpPlanCommand()" + } else { + "McpPlanCommand()" + } + } + + fn __copy__(&self) -> PyResult<()> { + Err(plan_command_error()) + } + + fn __deepcopy__(&self, _memo: &Bound<'_, PyAny>) -> PyResult<()> { + Err(plan_command_error()) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(plan_command_error()) + } + + fn __reduce_ex__(&self, _protocol: i32) -> PyResult<()> { + Err(plan_command_error()) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(plan_command_error()) + } +} + +impl PyMcpPlanCommand { + fn commands(&self) -> PyResult<&[McpCommand]> { + self.commands + .as_deref() + .ok_or_else(|| PyRuntimeError::new_err("MCP plan command has already been consumed")) + } +} + +impl PyMcpCommand { + fn command(&self) -> PyResult<&McpCommand> { + self.inner + .as_ref() + .ok_or_else(|| PyRuntimeError::new_err("MCP command has already been consumed")) + } + + fn action_commitment_bytes(&self) -> PyResult<[u8; 32]> { + canonical_action_commitment(self.command()?.call()) + } +} + +#[pyclass(name = "McpGatewayCall", frozen, module = "auths._native")] +pub struct PyMcpGatewayCall { + service: String, + name: String, + arguments_json: Vec, +} + +#[pymethods] +impl PyMcpGatewayCall { + #[getter] + fn service(&self) -> &str { + &self.service + } + + #[getter] + fn name(&self) -> &str { + &self.name + } + + #[getter] + fn arguments_json<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.arguments_json) + } +} + +#[pyfunction] +fn validate_mcp_service(service: &str) -> PyResult<()> { + McpToolCall::new(service, "profile-binding", Map::new()).map_err(value_error)?; + Ok(()) +} + +#[pyfunction] +fn mcp_call(service: &str, name: &str, arguments_json: &[u8]) -> PyResult { + if arguments_json.is_empty() || arguments_json.len() > MAX_CANONICAL_CALL_BYTES { + return Err(PyValueError::new_err("MCP arguments exceed native limits")); + } + let Value::Object(arguments) = + serde_json::from_slice::(arguments_json).map_err(value_error)? + else { + return Err(PyValueError::new_err("MCP arguments must be a JSON object")); + }; + Ok(PyMcpCall { + inner: McpToolCall::new(service, name, arguments).map_err(value_error)?, + }) +} + +#[pyfunction] +fn review_mcp_call<'py>( + py: Python<'py>, + call: PyRef<'_, PyMcpCall>, +) -> PyResult> { + let canonical = McpProfile + .canonicalize(&call.inner.canonical_bytes().map_err(value_error)?) + .map_err(value_error)?; + let display = McpProfile.review_display(&canonical).map_err(value_error)?; + let commitment = canonical_action_commitment(&call.inner)?; + Ok(( + display.title().to_owned(), + display.fields().to_vec(), + PyBytes::new(py, &commitment), + )) +} + +#[pyfunction] +fn commit_mcp_plan(py: Python<'_>, calls: Vec>) -> PyResult { + if calls.is_empty() || calls.len() > 256 { + return Err(PyValueError::new_err( + "MCP plan action count is outside native limits", + )); + } + let calls = calls + .iter() + .map(|call| call.borrow(py).inner.clone()) + .collect::>(); + let members = calls + .iter() + .map(canonical_plan_member) + .collect::>>()?; + let borrowed = members.iter().map(Vec::as_slice).collect::>(); + let commitment = ProfilePlanCommitment::commit(PROFILE_ID, PROFILE_VERSION, &borrowed) + .map_err(value_error)?; + let permissions = calls + .iter() + .map(|call| { + let canonical = McpProfile + .canonicalize(&call.canonical_bytes().map_err(value_error)?) + .map_err(value_error)?; + Ok(( + canonical.permission().capability().as_str().to_owned(), + canonical.permission().resource().as_str().to_owned(), + )) + }) + .collect::>>()?; + let first = calls + .first() + .ok_or_else(|| PyValueError::new_err("MCP plan action count is outside native limits"))?; + let resource_namespaces = vec![format!("mcp://{}", first.service())]; + let audiences = vec![first.audience().map_err(value_error)?.to_string()]; + Ok(PyNativeMcpPlan { + commitment: *commitment.plan().as_bytes(), + members: commitment + .members() + .iter() + .map(|member| *member.as_bytes()) + .collect(), + permissions, + resource_namespaces, + audiences, + }) +} + +#[pyfunction] +fn prepare_mcp_call_action( + call: PyRef<'_, PyMcpCall>, + actor: PyRef<'_, PyPrincipal>, + terminal_grant: PyRef<'_, PySignedObject>, + challenge: &[u8], + evaluation_time: u64, +) -> PyResult { + let SignedObject::Grant(terminal_grant) = &terminal_grant.inner else { + return Err(PyTypeError::new_err( + "terminal grant must be a signed grant", + )); + }; + let profile = McpProfile; + let canonical = profile + .canonicalize(&call.inner.canonical_bytes().map_err(value_error)?) + .map_err(value_error)?; + let display = profile.review_display(&canonical).map_err(value_error)?; + let challenge: [u8; 32] = challenge + .try_into() + .map_err(|_| PyValueError::new_err("challenge must contain 32 bytes"))?; + let prepared = prepare_profile_action( + canonical, + call.inner.audience().map_err(value_error)?, + actor.inner.clone(), + terminal_grant, + challenge, + evaluation_time, + ) + .map_err(value_error)?; + let (canonical, envelope) = prepared.into_parts(); + Ok(PyMcpAction { + arguments_json: serde_json_canonicalizer::to_vec(call.inner.arguments()) + .map_err(value_error)?, + audience: call.inner.audience().map_err(value_error)?.to_string(), + resource: canonical.permission().resource().to_string(), + display_digest_hex: display.canonical_digest_hex().to_owned(), + review_title: display.title().to_owned(), + review_fields: display.fields().to_vec(), + canonical, + envelope, + }) +} + +#[pyfunction] +fn authorize_mcp( + py: Python<'_>, + prepared: PyRef<'_, PyMcpAction>, + signed_action: PyRef<'_, PySignedObject>, + grants: Vec>, + grant_evidence: Vec)>>, + action_evidence: Vec<(String, String, Vec)>, + context: PyRef<'_, PyTrustedContext>, +) -> PyResult<(NativeVerificationResult, Option)> { + if grants.len() != grant_evidence.len() { + return Err(PyValueError::new_err( + "each grant requires one evidence collection", + )); + } + let SignedObject::Action(action) = &signed_action.inner else { + return Err(PyTypeError::new_err("signed action must be an action")); + }; + if action.envelope() != &prepared.envelope { + return Err(PyValueError::new_err( + "signed action does not match its native preparation", + )); + } + let mut builder = WorkflowProofBuilder::new(); + for (grant, evidence) in grants.iter().zip(grant_evidence) { + let grant = grant.borrow(py); + let SignedObject::Grant(grant) = &grant.inner else { + return Err(PyTypeError::new_err("grant chain contains a non-grant")); + }; + let index = builder.push_grant(grant.clone()).map_err(value_error)?; + for (evidence_type, media_type, bytes) in evidence { + builder + .bind_grant_evidence(index, evidence_object(&evidence_type, &media_type, bytes)?) + .map_err(value_error)?; + } + } + for (evidence_type, media_type, bytes) in action_evidence { + builder + .bind_action_evidence(evidence_object(&evidence_type, &media_type, bytes)?) + .map_err(value_error)?; + } + let artifacts = builder + .finish(action, &prepared.canonical, &context.inner) + .map_err(value_error)?; + let proof_cbor = auths_codec::encode_bundle(artifacts.proof()).map_err(value_error)?; + let action_cbor = + auths_codec::encode_canonical_action(&prepared.canonical).map_err(value_error)?; + let context_cbor = + auths_codec::encode_verifier_context(artifacts.context()).map_err(value_error)?; + let authority_commitment = *auths_codec::proof_digest(artifacts.proof()) + .map_err(value_error)? + .as_bytes(); + let context_commitment = *auths_codec::context_digest(artifacts.context()) + .map_err(value_error)? + .as_bytes(); + let sealed = verify_sealed(&proof_cbor, &action_cbor, &context_cbor)?; + let command = sealed + .action() + .map(|action| McpProfile.decode_verified(action)) + .transpose() + .map_err(value_error)? + .map(|inner| PyMcpCommand { + inner: Some(inner), + authority_commitment, + context_commitment, + }); + Ok((native_result(py, sealed)?, command)) +} + +#[pyfunction] +fn consume_mcp_command( + mut command: PyRefMut<'_, PyMcpCommand>, + expected_service: &str, +) -> PyResult { + if command.command()?.call().service() != expected_service { + return Err(PyTypeError::new_err( + "MCP command does not belong to this gateway", + )); + } + let command = command + .inner + .take() + .ok_or_else(|| PyRuntimeError::new_err("MCP command has already been consumed"))?; + Ok(PyMcpGatewayCall { + service: command.call().service().to_owned(), + name: command.name().to_owned(), + arguments_json: serde_json_canonicalizer::to_vec(command.arguments()) + .map_err(value_error)?, + }) +} + +#[pyfunction] +fn seal_mcp_plan_command( + py: Python<'_>, + commands: Vec>, + expected_service: &str, + expected_commitment: &[u8], +) -> PyResult { + if commands.is_empty() || commands.len() > 256 { + return Err(PyValueError::new_err( + "MCP plan command count is outside native limits", + )); + } + let expected: [u8; 32] = expected_commitment + .try_into() + .map_err(|_| PyValueError::new_err("plan commitment must contain 32 bytes"))?; + let mut identities = std::collections::HashSet::with_capacity(commands.len()); + if commands + .iter() + .any(|command| !identities.insert(command.as_ptr() as usize)) + { + return Err(PyValueError::new_err( + "MCP plan contains a duplicate command handle", + )); + } + let members = commands + .iter() + .map(|command| { + let command = command.borrow(py); + if command.command()?.call().service() != expected_service { + return Err(PyTypeError::new_err( + "MCP command does not belong to this plan", + )); + } + canonical_plan_member(command.command()?.call()) + }) + .collect::>>()?; + let borrowed = members.iter().map(Vec::as_slice).collect::>(); + let commitment = ProfilePlanCommitment::commit(PROFILE_ID, PROFILE_VERSION, &borrowed) + .map_err(value_error)?; + if commitment.plan().as_bytes() != &expected { + return Err(PyValueError::new_err( + "verified commands do not match the exact MCP plan", + )); + } + let receipt_bindings = commands + .iter() + .map(|command| { + let command = command.borrow(py); + Ok(( + command.action_commitment_bytes()?, + command.authority_commitment, + command.context_commitment, + )) + }) + .collect::>>()?; + let inner = commands + .iter() + .map(|command| { + command + .borrow_mut(py) + .inner + .take() + .ok_or_else(|| PyRuntimeError::new_err("MCP command has already been consumed")) + }) + .collect::>>()?; + Ok(PyMcpPlanCommand { + commands: Some(inner), + commitment: expected, + receipt_bindings, + }) +} + +#[pyfunction] +fn consume_mcp_plan_command( + mut command: PyRefMut<'_, PyMcpPlanCommand>, + expected_service: &str, +) -> PyResult> { + if command + .commands()? + .iter() + .any(|member| member.call().service() != expected_service) + { + return Err(PyTypeError::new_err( + "MCP plan command does not belong to this gateway", + )); + } + let commands = command + .commands + .take() + .ok_or_else(|| PyRuntimeError::new_err("MCP plan command has already been consumed"))?; + commands + .into_iter() + .map(|member| { + Ok(PyMcpGatewayCall { + service: member.call().service().to_owned(), + name: member.name().to_owned(), + arguments_json: serde_json_canonicalizer::to_vec(member.arguments()) + .map_err(value_error)?, + }) + }) + .collect() +} + +fn evidence_object( + evidence_type: &str, + media_type: &str, + bytes: Vec, +) -> PyResult { + address_evidence( + EvidenceTypeId::parse(evidence_type).map_err(value_error)?, + MediaType::parse(media_type).map_err(value_error)?, + bytes, + ) + .map_err(value_error) +} + +fn command_error() -> PyErr { + PyTypeError::new_err("McpCommand is a non-copyable native capability") +} + +fn plan_command_error() -> PyErr { + PyTypeError::new_err("McpPlanCommand is a non-copyable native capability") +} + +fn canonical_plan_member(call: &McpToolCall) -> PyResult> { + let canonical = McpProfile + .canonicalize(&call.canonical_bytes().map_err(value_error)?) + .map_err(value_error)?; + ProfilePlanMember::encode( + &canonical, + &ResourceId::parse(&format!("mcp://{}", call.service())).map_err(value_error)?, + &call.audience().map_err(value_error)?, + ) + .map_err(value_error) +} + +fn canonical_action_commitment(call: &McpToolCall) -> PyResult<[u8; 32]> { + let canonical = McpProfile + .canonicalize(&call.canonical_bytes().map_err(value_error)?) + .map_err(value_error)?; + let encoded = auths_codec::encode_canonical_action(&canonical).map_err(value_error)?; + Ok( + *auths_codec::domain_commitment("auths.canonical-action.v1", &encoded) + .map_err(value_error)? + .as_bytes(), + ) +} + +pub fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_function(wrap_pyfunction!(validate_mcp_service, module)?)?; + module.add_function(wrap_pyfunction!(mcp_call, module)?)?; + module.add_function(wrap_pyfunction!(review_mcp_call, module)?)?; + module.add_function(wrap_pyfunction!(commit_mcp_plan, module)?)?; + module.add_function(wrap_pyfunction!(prepare_mcp_call_action, module)?)?; + module.add_function(wrap_pyfunction!(authorize_mcp, module)?)?; + module.add_function(wrap_pyfunction!(consume_mcp_command, module)?)?; + module.add_function(wrap_pyfunction!(seal_mcp_plan_command, module)?)?; + module.add_function(wrap_pyfunction!(consume_mcp_plan_command, module)?)?; + Ok(()) +} diff --git a/bindings/python/src/result.rs b/bindings/python/src/result.rs new file mode 100644 index 00000000..b58b4b93 --- /dev/null +++ b/bindings/python/src/result.rs @@ -0,0 +1,289 @@ +#![allow(clippy::needless_pass_by_value, clippy::unused_self)] + +use auths_model::{ + DEFAULT_MAX_ACTION_BYTES, DEFAULT_MAX_BUNDLE_BYTES, DEFAULT_MAX_CONTEXT_BYTES, + PortableVerificationResult, VerificationDecision, VerificationStage, +}; +use auths_ports::{PrincipalMethod, SignatureSuite}; +use pyo3::{ + exceptions::{PyRuntimeError, PyTypeError, PyValueError}, + prelude::*, + types::PyBytes, +}; +use subtle::ConstantTimeEq as _; + +pub const NATIVE_ABI_VERSION: u16 = 2; +const MAX_VERIFY_BATCH: usize = 1_024; +const MAX_VERIFY_BATCH_BYTES: usize = 64 * 1024 * 1024; + +#[pyclass(name = "VerifiedAction", frozen, module = "auths._native")] +pub struct PyVerifiedAction { + pub(crate) inner: auths_verifier::VerifiedAction, +} + +#[pymethods] +impl PyVerifiedAction { + fn __repr__(&self) -> &'static str { + "VerifiedAction()" + } + + fn __copy__(&self) -> PyResult<()> { + Err(sealed_error()) + } + + fn __deepcopy__(&self, _memo: &Bound<'_, PyAny>) -> PyResult<()> { + Err(sealed_error()) + } + + fn __reduce__(&self) -> PyResult<()> { + Err(sealed_error()) + } + + fn __reduce_ex__(&self, _protocol: i32) -> PyResult<()> { + Err(sealed_error()) + } + + fn __getstate__(&self) -> PyResult<()> { + Err(sealed_error()) + } +} + +#[pyclass(name = "NativeVerificationResult", frozen, module = "auths._native")] +pub struct NativeVerificationResult { + portable: PortableVerificationResult, + result_cbor: Vec, + action: Option>, +} + +#[pymethods] +impl NativeVerificationResult { + #[getter] + fn kind(&self) -> &'static str { + decision_label(self.portable.decision()) + } + + #[getter] + fn code(&self) -> &'static str { + self.portable.code().code() + } + + #[getter] + fn stage(&self) -> &'static str { + stage_label(self.portable.stage()) + } + + #[getter] + fn metrics(&self) -> (u64, u64, u64, u64, u64, u64, u64) { + let resources = self.portable.resources(); + ( + resources.proof_bytes(), + resources.action_bytes(), + resources.context_bytes(), + resources.object_count(), + resources.plan_leaves(), + resources.plan_depth(), + resources.work_units(), + ) + } + + #[getter] + fn required_configuration<'py>(&self, py: Python<'py>) -> Option> { + self.portable + .required_configuration() + .map(|value| PyBytes::new(py, value.as_bytes())) + } + + #[getter] + fn local_configuration<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, self.portable.local_configuration().as_bytes()) + } + + #[getter] + fn result_cbor<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.result_cbor) + } + + #[getter] + fn action(&self, py: Python<'_>) -> Option> { + self.action.as_ref().map(|action| action.clone_ref(py)) + } +} + +#[pyfunction] +fn native_abi_version() -> u16 { + NATIVE_ABI_VERSION +} + +#[pyfunction] +fn verify_v1( + py: Python<'_>, + proof_cbor: &[u8], + canonical_action_cbor: &[u8], + trusted_context_cbor: &[u8], +) -> PyResult { + let proof = proof_cbor.to_vec(); + let action = canonical_action_cbor.to_vec(); + let context = trusted_context_cbor.to_vec(); + let sealed = py.detach(move || verify_sealed(&proof, &action, &context))?; + native_result(py, sealed) +} + +#[pyfunction] +fn verify_many_v1( + py: Python<'_>, + inputs: Vec<(Vec, Vec, Vec)>, +) -> PyResult> { + if inputs.is_empty() || inputs.len() > MAX_VERIFY_BATCH { + return Err(PyValueError::new_err( + "verification batch is outside native limits", + )); + } + let total_bytes = inputs.iter().try_fold(0_usize, |total, value| { + total + .checked_add(value.0.len())? + .checked_add(value.1.len())? + .checked_add(value.2.len()) + }); + if total_bytes.is_none_or(|total| total > MAX_VERIFY_BATCH_BYTES) { + return Err(PyValueError::new_err( + "verification batch is outside native limits", + )); + } + let sealed = py.detach(move || { + inputs + .iter() + .map(|(proof, action, context)| verify_sealed(proof, action, context)) + .collect::>>() + })?; + sealed + .into_iter() + .map(|value| native_result(py, value)) + .collect() +} + +#[pyfunction] +fn decode_diagnostic_result_v1(result_cbor: &[u8]) -> PyResult { + let portable = auths_codec::decode_verification_result(result_cbor).map_err(runtime_error)?; + Ok(NativeVerificationResult { + portable, + result_cbor: result_cbor.to_vec(), + action: None, + }) +} + +#[pyfunction] +fn commit_canonical_v1<'py>( + py: Python<'py>, + domain: &str, + canonical: &[u8], +) -> PyResult> { + let commitment = auths_codec::domain_commitment(domain, canonical).map_err(runtime_error)?; + Ok(PyBytes::new(py, commitment.as_bytes())) +} + +#[pyfunction] +const fn diagnostic_input_limits_v1() -> (usize, usize, usize) { + ( + DEFAULT_MAX_BUNDLE_BYTES, + DEFAULT_MAX_ACTION_BYTES, + DEFAULT_MAX_CONTEXT_BYTES, + ) +} + +#[pyfunction] +fn commitments_equal_v1(left: &[u8], right: &[u8]) -> PyResult { + if left.len() != 32 || right.len() != 32 { + return Err(PyValueError::new_err( + "native commitments must contain 32 bytes", + )); + } + Ok(bool::from(left.ct_eq(right))) +} + +pub(crate) fn verify_sealed( + proof_cbor: &[u8], + canonical_action_cbor: &[u8], + trusted_context_cbor: &[u8], +) -> PyResult { + let raw_key = auths_raw_key::RawKeyMethod::new().map_err(runtime_error)?; + let did_key = auths_did_key::DidKeyMethod::new().map_err(runtime_error)?; + let did_keri = auths_did_keri::DidKeriMethod::new().map_err(runtime_error)?; + let ed25519 = auths_signature::Ed25519Suite::new().map_err(runtime_error)?; + let p256 = auths_signature::P256Sha256Suite::new().map_err(runtime_error)?; + let methods: [&dyn PrincipalMethod; 3] = [&raw_key, &did_key, &did_keri]; + let suites: [&dyn SignatureSuite; 2] = [&ed25519, &p256]; + let registries = + auths_registries::ImmutableRegistries::new(&methods, &suites).map_err(runtime_error)?; + auths_verifier::verify_v1_sealed( + proof_cbor, + canonical_action_cbor, + trusted_context_cbor, + ®istries, + ) + .map_err(runtime_error) +} + +pub(crate) fn native_result( + py: Python<'_>, + sealed: auths_verifier::SealedVerificationResult, +) -> PyResult { + let (portable, result_cbor, action) = sealed.into_parts(); + let action = action + .map(|action| Py::new(py, PyVerifiedAction { inner: *action })) + .transpose()?; + Ok(NativeVerificationResult { + portable, + result_cbor, + action, + }) +} + +#[pyfunction] +fn inspect_verified_action<'py>( + py: Python<'py>, + action: PyRef<'_, PyVerifiedAction>, +) -> PyResult> { + let cbor = auths_codec::encode_canonical_action(action.inner.canonical_action()) + .map_err(runtime_error)?; + Ok(PyBytes::new(py, &cbor)) +} + +pub fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_function(wrap_pyfunction!(native_abi_version, module)?)?; + module.add_function(wrap_pyfunction!(verify_v1, module)?)?; + module.add_function(wrap_pyfunction!(verify_many_v1, module)?)?; + module.add_function(wrap_pyfunction!(decode_diagnostic_result_v1, module)?)?; + module.add_function(wrap_pyfunction!(commit_canonical_v1, module)?)?; + module.add_function(wrap_pyfunction!(diagnostic_input_limits_v1, module)?)?; + module.add_function(wrap_pyfunction!(commitments_equal_v1, module)?)?; + module.add_function(wrap_pyfunction!(inspect_verified_action, module)?)?; + Ok(()) +} + +fn decision_label(decision: VerificationDecision) -> &'static str { + match decision { + VerificationDecision::Authorized => "authorized", + VerificationDecision::Denied => "denied", + VerificationDecision::Indeterminate => "indeterminate", + } +} + +fn stage_label(stage: VerificationStage) -> &'static str { + match stage { + VerificationStage::Decode => "decode", + VerificationStage::Resolve => "resolve", + VerificationStage::PrincipalControl => "principal-control", + VerificationStage::Authority => "authority", + VerificationStage::Complete => "complete", + } +} + +fn sealed_error() -> PyErr { + PyTypeError::new_err("VerifiedAction is a non-copyable native capability") +} + +fn runtime_error(error: impl std::fmt::Display) -> PyErr { + PyRuntimeError::new_err(error.to_string()) +} diff --git a/bindings/python/src/runtime.rs b/bindings/python/src/runtime.rs new file mode 100644 index 00000000..747a4ca6 --- /dev/null +++ b/bindings/python/src/runtime.rs @@ -0,0 +1,166 @@ +use auths_lifecycle::{ + LifecycleState, + kernel::{ + KernelCode, OperationCode, ReplayCode, TransitionGates, additive_capacity_available, + exclusive_capacity_available, replay_code, transition_code, + }, +}; +use pyo3::{exceptions::PyValueError, prelude::*}; + +#[pyfunction] +#[allow(clippy::fn_params_excessive_bools, clippy::too_many_arguments)] +fn runtime_transition_v1( + current: Option<&str>, + operation: &str, + core_authorized: bool, + policy_eligible: bool, + configuration_matches: bool, + not_revoked: bool, + not_expired: bool, + capacity_available: bool, + execution_intent_present: bool, + credential_authorized: bool, + attempt_present: bool, + provider_call_entered: bool, + cancellation_allowed: bool, + definite_effect: bool, + definite_non_effect: bool, + reconciliation_fresh: bool, + reconciliation_matches: bool, +) -> PyResult<(String, Option)> { + let code = transition_code( + current.map(parse_state).transpose()?, + parse_operation(operation)?, + TransitionGates { + core_authorized, + policy_eligible, + configuration_matches, + not_revoked, + not_expired, + capacity_available, + execution_intent_present, + credential_authorized, + attempt_present, + provider_call_entered, + cancellation_allowed, + definite_effect, + definite_non_effect, + reconciliation_fresh, + reconciliation_matches, + }, + ); + Ok(match code { + KernelCode::Applied(state) => ("applied".into(), Some(state_label(state).into())), + KernelCode::ObservationOnly => ("observation-only".into(), current.map(str::to_owned)), + other => ("rejected".into(), Some(kernel_code(other).into())), + }) +} + +#[pyfunction] +fn runtime_replay_v1(record_exists: bool, commitments_equal: bool) -> &'static str { + match replay_code(record_exists, commitments_equal) { + ReplayCode::Absent => "absent", + ReplayCode::ExactReplay => "exact-replay", + ReplayCode::Conflict => "conflict", + } +} + +#[pyfunction] +fn runtime_additive_capacity_v1(ceiling: u64, committed: u64, active: u64, requested: u64) -> bool { + additive_capacity_available(ceiling, committed, active, requested) +} + +#[pyfunction] +fn runtime_exclusive_capacity_v1(has_live_owner: bool, owner_is_exact_replay: bool) -> bool { + exclusive_capacity_available(has_live_owner, owner_is_exact_replay) +} + +#[pyfunction] +fn runtime_execution_state_v1(outcome: &str) -> PyResult<&'static str> { + match outcome { + "succeeded" => Ok("committed"), + "cancelled" | "outcome-unknown" => Ok("outcome-unknown"), + _ => Err(PyValueError::new_err( + "unsupported observed execution outcome", + )), + } +} + +fn parse_state(value: &str) -> PyResult { + match value { + "decision-recorded" => Ok(LifecycleState::DecisionRecorded), + "reserved" => Ok(LifecycleState::Reserved), + "execution-intent-recorded" => Ok(LifecycleState::ExecutionIntentRecorded), + "executing" => Ok(LifecycleState::Executing), + "committed" => Ok(LifecycleState::Committed), + "released" => Ok(LifecycleState::Released), + "outcome-unknown" => Ok(LifecycleState::OutcomeUnknown), + "reconciled-committed" => Ok(LifecycleState::ReconciledCommitted), + "reconciled-released" => Ok(LifecycleState::ReconciledReleased), + _ => Err(PyValueError::new_err("unsupported runtime state")), + } +} + +fn state_label(value: LifecycleState) -> &'static str { + match value { + LifecycleState::DecisionRecorded => "decision-recorded", + LifecycleState::Reserved => "reserved", + LifecycleState::ExecutionIntentRecorded => "execution-intent-recorded", + LifecycleState::Executing => "executing", + LifecycleState::Committed => "committed", + LifecycleState::Released => "released", + LifecycleState::OutcomeUnknown => "outcome-unknown", + LifecycleState::ReconciledCommitted => "reconciled-committed", + LifecycleState::ReconciledReleased => "reconciled-released", + } +} + +fn parse_operation(value: &str) -> PyResult { + match value { + "record-decision" => Ok(OperationCode::RecordDecision), + "reserve" => Ok(OperationCode::Reserve), + "record-execution-intent" => Ok(OperationCode::RecordExecutionIntent), + "authorize-credential" => Ok(OperationCode::AuthorizeCredential), + "start-attempt" => Ok(OperationCode::StartAttempt), + "mark-provider-call-entered" => Ok(OperationCode::MarkProviderCallEntered), + "commit" => Ok(OperationCode::Commit), + "release" => Ok(OperationCode::Release), + "mark-outcome-unknown" => Ok(OperationCode::MarkOutcomeUnknown), + "reconcile-effect" => Ok(OperationCode::ReconcileEffect), + "reconcile-non-effect" => Ok(OperationCode::ReconcileNonEffect), + "reconcile-inconclusive" => Ok(OperationCode::ReconcileInconclusive), + _ => Err(PyValueError::new_err("unsupported runtime operation")), + } +} + +fn kernel_code(value: KernelCode) -> &'static str { + match value { + KernelCode::Applied(_) => "applied", + KernelCode::ObservationOnly => "observation-only", + KernelCode::Terminal => "terminal", + KernelCode::IllegalTransition => "illegal-transition", + KernelCode::NotAuthorized => "not-authorized", + KernelCode::NotEligible => "not-eligible", + KernelCode::ConfigurationMismatch => "configuration-mismatch", + KernelCode::Revoked => "revoked", + KernelCode::Expired => "expired", + KernelCode::CapacityExceeded => "capacity-exceeded", + KernelCode::ExecutionIntentMissing => "execution-intent-missing", + KernelCode::CredentialNotAuthorized => "credential-not-authorized", + KernelCode::AttemptMissing => "attempt-missing", + KernelCode::ProviderCallNotEntered => "provider-call-not-entered", + KernelCode::EffectNotProved => "effect-not-proved", + KernelCode::NonEffectNotProved => "non-effect-not-proved", + KernelCode::ReconciliationStale => "reconciliation-stale", + KernelCode::ReconciliationMismatch => "reconciliation-mismatch", + } +} + +pub fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_function(wrap_pyfunction!(runtime_transition_v1, module)?)?; + module.add_function(wrap_pyfunction!(runtime_replay_v1, module)?)?; + module.add_function(wrap_pyfunction!(runtime_additive_capacity_v1, module)?)?; + module.add_function(wrap_pyfunction!(runtime_exclusive_capacity_v1, module)?)?; + module.add_function(wrap_pyfunction!(runtime_execution_state_v1, module)?)?; + Ok(()) +} diff --git a/bindings/python/src/workflow.rs b/bindings/python/src/workflow.rs new file mode 100644 index 00000000..1b6cf829 --- /dev/null +++ b/bindings/python/src/workflow.rs @@ -0,0 +1,885 @@ +#![allow( + clippy::needless_pass_by_value, + clippy::too_many_arguments, + clippy::unused_self +)] + +use crate::authoring::{ + PyGrantPlan, PyPrincipal, PySignedObject, PyTrustedContext, PyUnsignedObject, SignedObject, + UnsignedObject, configuration, signing_descriptor, value_error, +}; +use auths_author::{ + ApprovalPolicyCommitment, AuthorityDimension, ExternalSigningRequest, GrantRequest, + PlanningError, plan_child_grant, prepare_action, prepare_grant, prepare_grant_status, + prepare_principal_status, +}; +use auths_custody::{ProviderSigningResponse, validate_provider_response}; +use auths_model::{ + ActionConstraint, ActionEnvelope, AssurancePolicyId, Audience, AudienceSet, BodyDigestSet, + BudgetAlgebraId, BudgetCeiling, Digest, FreshnessLimit, GrantStatement, GrantStatusStatement, + Permission, PermissionSet, PrincipalId, PrincipalStatusStatement, ProfileId, ProfileRef, + ResourceId, SignatureBytes, SignatureDescriptor, SignedGrant, StatusMethodId, StatusPolicy, + Timestamp, ValidityWindow, +}; +use pyo3::{ + create_exception, + exceptions::{PyRuntimeError, PyValueError}, + prelude::*, + types::PyBytes, +}; +use subtle::ConstantTimeEq as _; + +create_exception!(auths._native, NativeDelegationExpandedError, PyValueError); + +const MAX_IDENTIFIER_BYTES: usize = 128; + +#[derive(Clone)] +#[pyclass( + name = "PrincipalDescriptor", + frozen, + module = "auths._native", + skip_from_py_object +)] +pub struct PyPrincipalDescriptor { + principal: PrincipalId, + descriptor: SignatureDescriptor, +} + +#[pymethods] +impl PyPrincipalDescriptor { + #[new] + fn new( + principal: PyRef<'_, PyPrincipal>, + principal_method: &str, + verification_method: &str, + suite: &str, + ) -> PyResult { + Ok(Self { + principal: principal.inner.clone(), + descriptor: signing_descriptor(principal_method, verification_method, suite)?, + }) + } + + #[getter] + fn principal(&self) -> PyPrincipal { + PyPrincipal { + inner: self.principal.clone(), + } + } + + #[getter] + fn principal_method(&self) -> &str { + self.descriptor.principal_method().as_str() + } + + #[getter] + fn verification_method(&self) -> &str { + self.descriptor.verification_method().as_str() + } + + #[getter] + fn suite(&self) -> &str { + self.descriptor.suite().as_str() + } + + fn matches(&self, other: PyRef<'_, Self>) -> bool { + self.principal == other.principal && self.descriptor == other.descriptor + } + + fn __repr__(&self) -> String { + format!( + "PrincipalDescriptor(principal={:?}, principal_method={:?}, verification_method={:?}, suite={:?})", + self.principal.as_str(), + self.descriptor.principal_method().as_str(), + self.descriptor.verification_method().as_str(), + self.descriptor.suite().as_str() + ) + } +} + +#[derive(Clone)] +#[pyclass( + name = "ApprovalPolicyReference", + frozen, + module = "auths._native", + skip_from_py_object +)] +pub struct PyApprovalPolicyReference { + policy_id: String, + evaluator_version: String, + configuration_digest: [u8; 32], +} + +#[pymethods] +impl PyApprovalPolicyReference { + #[new] + fn new( + policy_id: &str, + evaluator_version: &str, + configuration_digest: &[u8], + ) -> PyResult { + Ok(Self { + policy_id: bounded_identifier(policy_id, "approval policy")?, + evaluator_version: bounded_identifier(evaluator_version, "evaluator version")?, + configuration_digest: array32(configuration_digest, "approval configuration")?, + }) + } + + #[getter] + fn policy_id(&self) -> &str { + &self.policy_id + } + + #[getter] + fn evaluator_version(&self) -> &str { + &self.evaluator_version + } + + #[getter] + fn configuration_digest<'py>(&self, py: Python<'py>) -> Bound<'py, PyBytes> { + PyBytes::new(py, &self.configuration_digest) + } + + fn matches(&self, other: PyRef<'_, Self>) -> bool { + policy_references_equal(self, &other) + } + + fn __repr__(&self) -> String { + format!( + "ApprovalPolicyReference(policy_id={:?}, evaluator_version={:?})", + self.policy_id, self.evaluator_version + ) + } +} + +#[pyfunction] +fn approval_policy_reference( + policy_id: &str, + evaluator_version: &str, + mode: &str, + max_uses: u32, + expires_in_seconds: u32, + requirements: Vec, +) -> PyResult { + let policy_id = bounded_identifier(policy_id, "approval policy")?; + let evaluator_version = bounded_identifier(evaluator_version, "evaluator version")?; + for requirement in &requirements { + bounded_identifier(requirement, "approval requirement")?; + } + let borrowed: Vec<&str> = requirements.iter().map(String::as_str).collect(); + let digest = ApprovalPolicyCommitment::commit( + bounded_identifier(mode, "approval mode")?.as_str(), + max_uses, + expires_in_seconds, + &borrowed, + ) + .map_err(value_error)?; + Ok(PyApprovalPolicyReference { + policy_id, + evaluator_version, + configuration_digest: *digest.as_bytes(), + }) +} + +#[derive(Clone, Copy)] +enum AuthorityBinding { + Root, + Delegated, +} + +#[pyclass(name = "GrantAuthority", frozen, module = "auths._native")] +pub struct PyGrantAuthority { + grant: SignedGrant, + binding: AuthorityBinding, +} + +#[pymethods] +impl PyGrantAuthority { + #[getter] + fn binding(&self) -> &'static str { + match self.binding { + AuthorityBinding::Root => "root", + AuthorityBinding::Delegated => "delegated", + } + } + + #[getter] + fn grant_id<'py>(&self, py: Python<'py>) -> PyResult> { + let id = auths_codec::grant_id(self.grant.statement()).map_err(value_error)?; + Ok(PyBytes::new(py, id.as_bytes())) + } + + #[getter] + fn issuer(&self) -> PyPrincipal { + PyPrincipal { + inner: self.grant.statement().issuer().clone(), + } + } + + #[getter] + fn subject(&self) -> PyPrincipal { + PyPrincipal { + inner: self.grant.statement().subject().clone(), + } + } + + #[getter] + fn profile(&self) -> (String, u16) { + ( + self.grant.statement().profile().id().as_str().to_owned(), + self.grant.statement().profile().version(), + ) + } + + #[getter] + fn permissions(&self) -> Vec<(String, String)> { + self.grant + .statement() + .permissions() + .as_slice() + .iter() + .map(|permission| { + ( + permission.capability().as_str().to_owned(), + permission.resource().as_str().to_owned(), + ) + }) + .collect() + } + + #[getter] + fn validity(&self) -> (u64, u64) { + let validity = self.grant.statement().validity(); + (validity.not_before().get(), validity.expires_at().get()) + } + + #[getter] + fn audiences(&self) -> Vec { + self.grant + .statement() + .audiences() + .as_slice() + .iter() + .map(|audience| audience.as_str().to_owned()) + .collect() + } + + #[getter] + fn action_constraint(&self) -> (&'static str, usize) { + match self.grant.statement().action_constraint() { + ActionConstraint::AnyBody => ("any-body", 0), + ActionConstraint::ExactBodyDigest(_) => ("exact-body", 1), + ActionConstraint::AllowedBodyDigests(digests) => { + ("allowed-bodies", digests.as_slice().len()) + } + } + } + + #[getter] + fn budget(&self) -> Option<(String, u64)> { + self.grant + .statement() + .budget_ceiling() + .map(|budget| (budget.algebra().as_str().to_owned(), budget.value())) + } + + #[getter] + fn remaining_depth(&self) -> u16 { + self.grant.statement().remaining_depth() + } + + #[getter] + fn parent_id<'py>(&self, py: Python<'py>) -> Option> { + self.grant + .statement() + .parent() + .map(|parent| PyBytes::new(py, parent.as_bytes())) + } + + #[getter] + fn status(&self) -> (&'static str, Option, Option) { + match self.grant.statement().status_policy() { + StatusPolicy::ExpiryOnly => ("expiry-only", None, None), + StatusPolicy::SnapshotRequired { method, max_age } => ( + "snapshot-required", + Some(method.as_str().to_owned()), + Some(max_age.get()), + ), + } + } + + #[getter] + fn assurance_floor(&self) -> &str { + self.grant.statement().assurance_floor().as_str() + } + + #[getter] + fn critical_extensions(&self) -> Vec { + self.grant + .statement() + .extensions() + .as_slice() + .iter() + .map(|extension| extension.id().as_str().to_owned()) + .collect() + } + + #[getter] + fn signature(&self) -> (String, String, String) { + let descriptor = self.grant.signature().descriptor(); + ( + descriptor.principal_method().as_str().to_owned(), + descriptor.verification_method().as_str().to_owned(), + descriptor.suite().as_str().to_owned(), + ) + } + + fn __repr__(&self) -> String { + format!( + "GrantAuthority(binding={:?}, issuer={:?}, subject={:?})", + self.binding(), + self.grant.statement().issuer().as_str(), + self.grant.statement().subject().as_str() + ) + } +} + +#[pyfunction] +fn validate_trusted_authority( + context: PyRef<'_, PyTrustedContext>, + root: PyRef<'_, PyPrincipal>, +) -> PyResult<()> { + if context.inner.configuration().as_bytes() != &configuration()? { + return Err(PyValueError::new_err( + "trusted authority requires a different verifier configuration", + )); + } + if !context + .inner + .trust_anchors() + .iter() + .any(|anchor| anchor.principal() == &root.inner) + { + return Err(PyValueError::new_err( + "trusted context does not contain the configured root", + )); + } + Ok(()) +} + +#[pyfunction] +fn validate_root_authority( + signed: PyRef<'_, PySignedObject>, + root: PyRef<'_, PyPrincipal>, + subject: PyRef<'_, PyPrincipalDescriptor>, + profile_id: &str, + profile_version: u16, +) -> PyResult { + let SignedObject::Grant(grant) = &signed.inner else { + return Err(PyValueError::new_err( + "root authority must be a signed grant", + )); + }; + let profile = ProfileRef::new( + ProfileId::parse(profile_id).map_err(value_error)?, + profile_version, + ) + .map_err(value_error)?; + let statement = grant.statement(); + if statement.parent().is_some() + || statement.issuer() != &root.inner + || statement.subject() != &subject.principal + || statement.profile() != &profile + { + return Err(PyValueError::new_err( + "signed grant does not bind the trusted root, agent, and profile", + )); + } + Ok(PyGrantAuthority { + grant: grant.clone(), + binding: AuthorityBinding::Root, + }) +} + +#[pyfunction] +fn bind_delegated_authority( + signed: PyRef<'_, PySignedObject>, + parent: PyRef<'_, PyGrantAuthority>, + subject: PyRef<'_, PyPrincipalDescriptor>, + issuer: PyRef<'_, PyPrincipalDescriptor>, + profile_id: &str, + profile_version: u16, +) -> PyResult { + let SignedObject::Grant(grant) = &signed.inner else { + return Err(PyValueError::new_err( + "delegated authority must be a signed grant", + )); + }; + let profile = ProfileRef::new( + ProfileId::parse(profile_id).map_err(value_error)?, + profile_version, + ) + .map_err(value_error)?; + let expected_parent = auths_codec::grant_id(parent.grant.statement()).map_err(value_error)?; + let statement = grant.statement(); + if statement.issuer() != &issuer.principal + || statement.subject() != &subject.principal + || statement.profile() != &profile + || statement.parent() != Some(expected_parent) + || grant.signature().descriptor() != &issuer.descriptor + { + return Err(PyValueError::new_err( + "signed child grant does not match its native delegation plan", + )); + } + Ok(PyGrantAuthority { + grant: grant.clone(), + binding: AuthorityBinding::Delegated, + }) +} + +#[pyfunction] +fn plan_child_fields( + parent: PyRef<'_, PyGrantAuthority>, + subject: PyRef<'_, PyPrincipalDescriptor>, + permissions: Vec<(String, String)>, + not_before: u64, + expires_at: u64, + audiences: Vec, + action_mode: &str, + action_digests: Vec>, + budget_mode: &str, + budget: Option<(String, u64)>, + remaining_depth: u16, + status_mode: &str, + status: Option<(String, u64)>, + assurance_floor: Option, +) -> PyResult { + let statement = parent.grant.statement(); + let permissions = PermissionSet::new( + permissions + .into_iter() + .map(|(capability, resource)| { + Ok(Permission::new( + auths_model::CapabilityId::parse(&capability).map_err(value_error)?, + ResourceId::parse(&resource).map_err(value_error)?, + )) + }) + .collect::>>()?, + ) + .map_err(value_error)?; + let audiences = AudienceSet::new( + audiences + .into_iter() + .map(|audience| Audience::parse(&audience).map_err(value_error)) + .collect::>>()?, + ) + .map_err(value_error)?; + let action_constraint = match action_mode { + "inherit" if action_digests.is_empty() => statement.action_constraint().clone(), + "any-body" if action_digests.is_empty() => ActionConstraint::AnyBody, + "exact-body" if action_digests.len() == 1 => ActionConstraint::ExactBodyDigest( + Digest::new(array32(&action_digests[0], "exact body digest")?), + ), + "allowed-bodies" if !action_digests.is_empty() => { + let values = action_digests + .iter() + .map(|digest| array32(digest, "allowed body digest").map(Digest::new)) + .collect::>>()?; + ActionConstraint::AllowedBodyDigests(BodyDigestSet::new(values).map_err(value_error)?) + } + _ => { + return Err(PyValueError::new_err("invalid delegated action constraint")); + } + }; + let budget_ceiling = match (budget_mode, budget) { + ("inherit", None) => statement.budget_ceiling().cloned(), + ("none", None) => None, + ("ceiling", Some((algebra, value))) => Some(BudgetCeiling::new( + BudgetAlgebraId::parse(&algebra).map_err(value_error)?, + value, + )), + _ => return Err(PyValueError::new_err("invalid delegated budget")), + }; + let status_policy = match (status_mode, status) { + ("inherit", None) => statement.status_policy().clone(), + ("expiry-only", None) => StatusPolicy::ExpiryOnly, + ("snapshot-required", Some((method, maximum_age))) => StatusPolicy::SnapshotRequired { + method: StatusMethodId::parse(&method).map_err(value_error)?, + max_age: FreshnessLimit::new(maximum_age).map_err(value_error)?, + }, + _ => return Err(PyValueError::new_err("invalid delegated status policy")), + }; + let assurance_floor = assurance_floor.map_or_else( + || Ok(statement.assurance_floor().clone()), + |value| AssurancePolicyId::parse(&value).map_err(value_error), + )?; + let request = GrantRequest::new( + subject.principal.clone(), + statement.profile().clone(), + permissions, + ValidityWindow::new(Timestamp::new(not_before), Timestamp::new(expires_at)) + .map_err(value_error)?, + audiences, + action_constraint, + budget_ceiling, + remaining_depth, + status_policy, + assurance_floor, + statement.extensions().clone(), + ); + let inner = plan_child_grant(statement, request).map_err(planning_error)?; + Ok(PyGrantPlan { inner }) +} + +enum WorkflowSigningRequest { + Grant(ExternalSigningRequest), + Action(ExternalSigningRequest), + PrincipalStatus(ExternalSigningRequest), + GrantStatus(ExternalSigningRequest), +} + +impl WorkflowSigningRequest { + fn request_id(&self) -> String { + match self { + Self::Grant(value) => value.request_id(), + Self::Action(value) => value.request_id(), + Self::PrincipalStatus(value) => value.request_id(), + Self::GrantStatus(value) => value.request_id(), + } + } + + fn object_kind(&self) -> &'static str { + match self { + Self::Grant(value) => value.object_id().label(), + Self::Action(value) => value.object_id().label(), + Self::PrincipalStatus(value) => value.object_id().label(), + Self::GrantStatus(value) => value.object_id().label(), + } + } + + fn object_id(&self) -> [u8; 32] { + match self { + Self::Grant(value) => *value.object_id().as_bytes(), + Self::Action(value) => *value.object_id().as_bytes(), + Self::PrincipalStatus(value) => *value.object_id().as_bytes(), + Self::GrantStatus(value) => *value.object_id().as_bytes(), + } + } + + fn signing_preimage(&self) -> &[u8] { + match self { + Self::Grant(value) => value.signing_preimage(), + Self::Action(value) => value.signing_preimage(), + Self::PrincipalStatus(value) => value.signing_preimage(), + Self::GrantStatus(value) => value.signing_preimage(), + } + } + + fn transaction_digest(&self) -> [u8; 32] { + match self { + Self::Grant(value) => *value.transaction_digest().as_bytes(), + Self::Action(value) => *value.transaction_digest().as_bytes(), + Self::PrincipalStatus(value) => *value.transaction_digest().as_bytes(), + Self::GrantStatus(value) => *value.transaction_digest().as_bytes(), + } + } + + fn complete_response( + self, + expected_principal: &PrincipalId, + response: ProviderSigningResponse, + ) -> PyResult { + match self { + Self::Grant(value) => { + let (signature, _) = + validate_provider_response(&value, expected_principal, response) + .map_err(value_error)? + .into_parts(); + Ok(SignedObject::Grant(value.complete(signature))) + } + Self::Action(value) => { + let (signature, _) = + validate_provider_response(&value, expected_principal, response) + .map_err(value_error)? + .into_parts(); + Ok(SignedObject::Action(value.complete(signature))) + } + Self::PrincipalStatus(value) => { + let (signature, _) = + validate_provider_response(&value, expected_principal, response) + .map_err(value_error)? + .into_parts(); + Ok(SignedObject::PrincipalStatus(value.complete(signature))) + } + Self::GrantStatus(value) => { + let (signature, _) = + validate_provider_response(&value, expected_principal, response) + .map_err(value_error)? + .into_parts(); + Ok(SignedObject::GrantStatus(value.complete(signature))) + } + } + } +} + +#[derive(Clone, Copy, Eq, PartialEq)] +enum TransactionPhase { + AwaitingApproval, + AwaitingSignature, + Terminal, +} + +#[pyclass(name = "SigningTransaction", module = "auths._native")] +pub struct PySigningTransaction { + request: Option, + principal: PyPrincipalDescriptor, + policy: PyApprovalPolicyReference, + expires_at: u64, + phase: TransactionPhase, +} + +#[pymethods] +impl PySigningTransaction { + #[getter] + fn object_kind(&self) -> PyResult<&'static str> { + Ok(self.request()?.object_kind()) + } + + #[getter] + fn request_id(&self) -> PyResult { + Ok(self.request()?.request_id()) + } + + #[getter] + fn object_id<'py>(&self, py: Python<'py>) -> PyResult> { + Ok(PyBytes::new(py, &self.request()?.object_id())) + } + + #[getter] + fn signing_preimage<'py>(&self, py: Python<'py>) -> PyResult> { + Ok(PyBytes::new(py, self.request()?.signing_preimage())) + } + + #[getter] + fn transaction_digest<'py>(&self, py: Python<'py>) -> PyResult> { + Ok(PyBytes::new(py, &self.request()?.transaction_digest())) + } + + #[getter] + fn principal(&self) -> PyPrincipalDescriptor { + self.principal.clone() + } + + #[getter] + fn policy(&self) -> PyApprovalPolicyReference { + self.policy.clone() + } + + #[getter] + fn expires_at(&self) -> u64 { + self.expires_at + } + + #[getter] + fn phase(&self) -> &'static str { + match self.phase { + TransactionPhase::AwaitingApproval => "awaiting-approval", + TransactionPhase::AwaitingSignature => "awaiting-signature", + TransactionPhase::Terminal => "terminal", + } + } + + fn accept_approval( + &mut self, + request_id: &str, + transaction_digest: &[u8], + policy: PyRef<'_, PyApprovalPolicyReference>, + decision: &str, + now: u64, + ) -> PyResult { + if self.phase != TransactionPhase::AwaitingApproval { + return Err(PyRuntimeError::new_err( + "signing transaction is not awaiting approval", + )); + } + let request = self + .request + .take() + .ok_or_else(|| PyRuntimeError::new_err("signing transaction is terminal"))?; + self.phase = TransactionPhase::Terminal; + if now > self.expires_at { + return Err(PyRuntimeError::new_err("signing transaction expired")); + } + let response_digest = array32(transaction_digest, "approval transaction digest")?; + if request.request_id() != request_id + || !constant_time_equal(&request.transaction_digest(), &response_digest) + || !policy_references_equal(&self.policy, &policy) + { + return Err(PyValueError::new_err( + "approval response is not bound to the exact transaction", + )); + } + match decision { + "approved" => { + self.request = Some(request); + self.phase = TransactionPhase::AwaitingSignature; + Ok(true) + } + "rejected" => Ok(false), + _ => Err(PyValueError::new_err("invalid approval decision")), + } + } + + fn complete_response( + &mut self, + request_id: &str, + principal: PyRef<'_, PyPrincipalDescriptor>, + transaction_digest: &[u8], + signature: &[u8], + now: u64, + ) -> PyResult { + if self.phase != TransactionPhase::AwaitingSignature { + return Err(PyRuntimeError::new_err( + "signing transaction is not awaiting a signature", + )); + } + let request = self + .request + .take() + .ok_or_else(|| PyRuntimeError::new_err("signing transaction is terminal"))?; + self.phase = TransactionPhase::Terminal; + if now > self.expires_at { + return Err(PyRuntimeError::new_err("signing transaction expired")); + } + let signature = SignatureBytes::new(signature.to_vec()).map_err(value_error)?; + let response = ProviderSigningResponse::new( + request_id.to_owned(), + principal.principal.clone(), + principal.descriptor.clone(), + signature, + Vec::new(), + array32(transaction_digest, "signer transaction digest")?, + ); + Ok(PySignedObject { + inner: request.complete_response(&self.principal.principal, response)?, + }) + } + + fn discard(&mut self) { + self.request = None; + self.phase = TransactionPhase::Terminal; + } + + fn __repr__(&self) -> String { + format!("SigningTransaction(phase={:?})", self.phase()) + } +} + +impl PySigningTransaction { + fn request(&self) -> PyResult<&WorkflowSigningRequest> { + self.request + .as_ref() + .ok_or_else(|| PyRuntimeError::new_err("signing transaction is terminal")) + } +} + +#[pyfunction] +fn prepare_signing_transaction( + unsigned: PyRef<'_, PyUnsignedObject>, + principal: PyRef<'_, PyPrincipalDescriptor>, + policy: PyRef<'_, PyApprovalPolicyReference>, + expires_at: u64, +) -> PyResult { + let descriptor = principal.descriptor.clone(); + let request = match &unsigned.inner { + UnsignedObject::Grant(value) => WorkflowSigningRequest::Grant( + prepare_grant(value.clone(), descriptor).map_err(value_error)?, + ), + UnsignedObject::Action(value) => WorkflowSigningRequest::Action( + prepare_action(value.clone(), descriptor).map_err(value_error)?, + ), + UnsignedObject::PrincipalStatus(value) => WorkflowSigningRequest::PrincipalStatus( + prepare_principal_status(value.clone(), descriptor).map_err(value_error)?, + ), + UnsignedObject::GrantStatus(value) => WorkflowSigningRequest::GrantStatus( + prepare_grant_status(value.clone(), descriptor).map_err(value_error)?, + ), + }; + Ok(PySigningTransaction { + request: Some(request), + principal: principal.clone(), + policy: policy.clone(), + expires_at, + phase: TransactionPhase::AwaitingApproval, + }) +} + +pub fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add( + "NativeDelegationExpandedError", + module.py().get_type::(), + )?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_function(wrap_pyfunction!(approval_policy_reference, module)?)?; + module.add_function(wrap_pyfunction!(validate_trusted_authority, module)?)?; + module.add_function(wrap_pyfunction!(validate_root_authority, module)?)?; + module.add_function(wrap_pyfunction!(bind_delegated_authority, module)?)?; + module.add_function(wrap_pyfunction!(plan_child_fields, module)?)?; + module.add_function(wrap_pyfunction!(prepare_signing_transaction, module)?)?; + Ok(()) +} + +fn bounded_identifier(value: &str, label: &str) -> PyResult { + if value.is_empty() || value.len() > MAX_IDENTIFIER_BYTES || value.chars().any(char::is_control) + { + return Err(PyValueError::new_err(format!("invalid {label}"))); + } + Ok(value.to_owned()) +} + +fn policy_references_equal( + left: &PyApprovalPolicyReference, + right: &PyApprovalPolicyReference, +) -> bool { + left.policy_id == right.policy_id + && left.evaluator_version == right.evaluator_version + && constant_time_equal(&left.configuration_digest, &right.configuration_digest) +} + +fn constant_time_equal(left: &[u8; 32], right: &[u8; 32]) -> bool { + bool::from(left.ct_eq(right)) +} + +fn planning_error(error: PlanningError) -> PyErr { + match error { + PlanningError::Expanded(dimension) => { + PyErr::new::(authority_dimension(dimension)) + } + other => value_error(other), + } +} + +const fn authority_dimension(value: AuthorityDimension) -> &'static str { + match value { + AuthorityDimension::Profile => "profile", + AuthorityDimension::Permissions => "permissions", + AuthorityDimension::Validity => "validity", + AuthorityDimension::Audiences => "audiences", + AuthorityDimension::ActionConstraint => "action-constraint", + AuthorityDimension::Budget => "budget", + AuthorityDimension::DelegationDepth => "delegation-depth", + AuthorityDimension::Status => "status", + AuthorityDimension::Assurance => "assurance", + AuthorityDimension::Extensions => "critical-extensions", + } +} + +fn array32(value: &[u8], label: &str) -> PyResult<[u8; 32]> { + value + .try_into() + .map_err(|_| PyValueError::new_err(format!("{label} must contain 32 bytes"))) +} diff --git a/bindings/python/tests/test_api.py b/bindings/python/tests/test_api.py index be67d999..461f6e21 100644 --- a/bindings/python/tests/test_api.py +++ b/bindings/python/tests/test_api.py @@ -1,48 +1,111 @@ from __future__ import annotations +import copy +import pickle from pathlib import Path +from typing import Callable import pytest import auths -from auths import Authorized, Denied, VerifiedAction, verify +import auths._native as native_implementation +from auths.verify import Authorized, Denied, verify +from auths.inspection import canonical_action_bytes +VerifiedAction = native_implementation.VerifiedAction -CORPUS = ( - Path(__file__).parents[3] - / "core" - / "fixtures" - / "v1" - / "valid" -) + +CORPUS = Path(__file__).parents[3] / "core" / "fixtures" / "v1" / "valid" BINDING_VECTORS = Path(__file__).parents[3] / "target" / "binding-vectors" -def test_native_api_returns_a_sealed_authorized_action() -> None: +def authorized_result() -> Authorized: result = verify( (CORPUS / "raw-key-chain.proof.cbor").read_bytes(), (CORPUS / "raw-key-chain.action.cbor").read_bytes(), (BINDING_VECTORS / "authorized.context.cbor").read_bytes(), ) - assert isinstance(result, Authorized) + return result + + +def native_action() -> VerifiedAction: + result = native_implementation.verify_v1( + (CORPUS / "raw-key-chain.proof.cbor").read_bytes(), + (CORPUS / "raw-key-chain.action.cbor").read_bytes(), + (BINDING_VECTORS / "authorized.context.cbor").read_bytes(), + ) + assert result.action is not None + return result.action + + +def test_public_verification_is_inert_and_native_api_seals_the_action() -> None: + result = authorized_result() + assert result.code == "authorized" assert result.required_configuration == result.local_configuration assert len(result.local_configuration) == 32 - assert result.action.canonical_bytes == ( + assert not hasattr(result, "action") + assert canonical_action_bytes(native_action()) == ( CORPUS / "raw-key-chain.action.cbor" ).read_bytes() - assert result.result_cbor == ( - BINDING_VECTORS / "authorized.result.cbor" - ).read_bytes() + assert ( + result.result_cbor == (BINDING_VECTORS / "authorized.result.cbor").read_bytes() + ) + + +def test_verified_action_has_no_python_construction_path() -> None: + operations: tuple[Callable[[], object], ...] = ( + lambda: VerifiedAction(), + lambda: object.__new__(VerifiedAction), + lambda: VerifiedAction.__new__(VerifiedAction), + ) + for operation in operations: + with pytest.raises(TypeError): + operation() + with pytest.raises(TypeError): + type("ForgedAction", (VerifiedAction,), {}) + + assert not hasattr(auths, "_AUTHORIZED_TOKEN") + assert not any( + isinstance(value, VerifiedAction) + for value in vars(native_implementation).values() + ) + assert not hasattr(native_action(), "__dict__") + + +def test_verified_action_rejects_copy_pickle_reduce_and_mutation() -> None: + action = native_action() + + operations: tuple[Callable[[], object], ...] = ( + lambda: copy.copy(action), + lambda: copy.deepcopy(action), + lambda: pickle.dumps(action), + lambda: action.__reduce__(), + lambda: action.__reduce_ex__(5), + ) + for operation in operations: + with pytest.raises(TypeError, match="native capability"): + operation() + with pytest.raises(AttributeError): + action.authorized = False # type: ignore[attr-defined] + with pytest.raises(AttributeError): + object.__setattr__(action, "authorized", False) + with pytest.raises(TypeError): + memoryview(action) # type: ignore[arg-type] -def test_verified_action_cannot_be_constructed_by_application_code() -> None: - with pytest.raises(TypeError, match="sealed"): - VerifiedAction(object(), b"unverified") +def test_canonical_bytes_do_not_promote_to_a_capability() -> None: + canonical = canonical_action_bytes(native_action()) + assert isinstance(canonical, bytes) + with pytest.raises(TypeError): + VerifiedAction(canonical) # type: ignore[call-arg] + with pytest.raises(TypeError): + canonical_action_bytes(canonical) # type: ignore[arg-type] -def test_configuration_mismatch_reports_required_and_executed_commitments() -> None: + +def test_configuration_mismatch_has_no_authorization_handle() -> None: result = verify( (CORPUS / "raw-key-chain.proof.cbor").read_bytes(), (CORPUS / "raw-key-chain.action.cbor").read_bytes(), @@ -55,23 +118,17 @@ def test_configuration_mismatch_reports_required_and_executed_commitments() -> N assert len(result.required_configuration) == 32 assert len(result.local_configuration) == 32 assert result.required_configuration != result.local_configuration + assert not hasattr(result, "action") -def test_portable_decoder_rejects_shape_version_and_trailing_data() -> None: - canonical = (BINDING_VECTORS / "authorized.result.cbor").read_bytes() - assert canonical[0] == 0xB0 - assert canonical[-2:] == b"\x0f\x02" - - with pytest.raises(ValueError, match="trailing"): - auths._decode_result(canonical + b"\x00") - - reordered_keys = bytes([0xB0, 0x01, 0x04, 0x00, 0x00]) + canonical[5:] - with pytest.raises(ValueError, match="canonical"): - auths._decode_result(reordered_keys) - - unknown_field = bytes([0xB1]) + canonical[1:] + b"\x10\xf6" - with pytest.raises(ValueError, match="shape"): - auths._decode_result(unknown_field) +def test_native_result_parser_preserves_decode_failure_codes() -> None: + result = verify( + (CORPUS / "raw-key-chain.proof.cbor").read_bytes(), + b"not-canonical-cbor", + (BINDING_VECTORS / "authorized.context.cbor").read_bytes(), + ) - with pytest.raises(ValueError, match="ABI version"): - auths._decode_result(canonical[:-1] + b"\x03") + assert isinstance(result, Denied) + assert result.stage == "decode" + assert result.code == "malformed-proof" + assert not hasattr(result, "action") diff --git a/bindings/python/tests/test_elite_sdk.py b/bindings/python/tests/test_elite_sdk.py new file mode 100644 index 00000000..b6e50339 --- /dev/null +++ b/bindings/python/tests/test_elite_sdk.py @@ -0,0 +1,383 @@ +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import pytest + +from auths import ( + Approval, + ApprovalRequest, + ApprovalResponse, + Permission, + Principal, + Profile, + ReviewField, +) +from auths.approvals import threshold_approval +from auths.authority import ProofPlanBuilder, ProofReference +from auths.diagnostics import runtime_diagnostic +from auths.identity import ( + IdentityRegistry, + ResolutionEvidence, + ResolvedIdentityRecord, + ResolverIdentityMethod, + VerificationMaterial, + VerificationRelationship, + decode_identity, + encode_identity, +) +from auths.integrations import exchange_identity +from auths.lifecycle import rotate_identity +from auths.observability import AuthsEvent, DecisionTimeline, support_bundle +from auths.profile_kit import ( + CanonicalProfileAction, + ProfileBudget, + ProfileDefinition, + ProfilePermission, + define_profile, +) +from auths.profiles.http import HttpProfile, HttpProfileError +from auths.runtime import InMemoryRuntimeStore, RuntimeKernel, TransitionGates +from auths.trust import ( + AssurancePolicy, + CompiledTrust, + TrustAnchor, + compile_trust, + replace_policy, +) +from auths.verify import verify, verify_many + +ROOT = Path(__file__).parents[3] +CORPUS = ROOT / "core" / "fixtures" / "v1" / "valid" +VECTORS = ROOT / "target" / "binding-vectors" + + +def test_identity_import_does_not_load_authority_workflow_or_profiles() -> None: + source = """ +import sys +import auths.identity +blocked = sorted(name for name in sys.modules if name in { + 'auths.workflow', 'auths.authority', 'auths.approvals', 'auths.trust', + 'auths.lifecycle', 'auths.runtime', 'auths.profile_kit', + 'auths.profiles.mcp', 'auths.profiles.http' +}) +print(','.join(blocked)) +""" + completed = subprocess.run( + [sys.executable, "-c", source], + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr + assert completed.stdout.strip() == "" + + +@pytest.mark.asyncio +async def test_resolver_and_hybrid_suite_own_verification_material_shape() -> None: + relationship = VerificationRelationship( + "hybrid-auth", + "authentication", + "hybrid-v1", + ( + VerificationMaterial("classical", b"classical-key"), + VerificationMaterial("post-quantum", b"post-quantum-key"), + ), + ) + + class Resolver: + async def resolve( + self, method_id: str, identity_id: str, *, maximum_bytes: int + ) -> ResolvedIdentityRecord: + assert maximum_bytes == 4096 + return ResolvedIdentityRecord( + method_id, + identity_id, + b"resolver-version-7", + (relationship,), + ResolutionEvidence("resolver", 10, 20, ("https",), ("rotated-1",)), + ) + + class HybridSuite: + suite_id = "hybrid-v1" + version = 1 + + async def verify( + self, + material: tuple[VerificationMaterial, ...], + preimage: bytes, + signature: bytes, + ) -> None: + assert tuple(value.material_id for value in material) == ( + "classical", + "post-quantum", + ) + assert preimage + if signature != b"valid-hybrid-signature": + raise ValueError("invalid hybrid signature") + + packet = encode_identity( + "did-web-v1", + "did:web:example.com:alice", + method_material=b"alice", + relationships=(relationship,), + ) + registry = IdentityRegistry( + methods=[ + ResolverIdentityMethod( + "did-web-v1", Resolver(), maximum_bytes=4096 + ) + ], + suites=[HybridSuite()], + ) + validated = await decode_identity(packet).validate(registry) + authenticated = await validated.authenticate( + b"application message", + b"valid-hybrid-signature", + registry, + relationship_id="hybrid-auth", + ) + principal = authenticated.validated.authority_input( + relationship_id="hybrid-auth", assurance="hybrid-reviewed" + ) + assert principal.method_id == "did-web-v1" + assert principal.suite_id == "hybrid-v1" + assert principal.provenance == ("https",) + + +def test_native_proof_plans_bind_composition_and_builder_ownership() -> None: + builder = ProofPlanBuilder() + first = builder.proof(ProofReference(bytes([1]) * 32)) + second = builder.proof(ProofReference(bytes([2]) * 32)) + all_plan = builder.all_of((first, second)) + any_plan = builder.any_of((first, second)) + threshold = builder.threshold(1, (first, second)) + assert all_plan.plan_id != any_plan.plan_id != threshold.plan_id + assert all_plan.leaf_count == 2 + assert threshold.maximum_depth == 2 + assert threshold.canonical_bytes() + foreign = ProofPlanBuilder().proof(ProofReference(bytes([3]) * 32)) + with pytest.raises(ValueError, match="another builder"): + builder.all_of((first, foreign)) + + +@pytest.mark.asyncio +async def test_identity_transport_carries_only_bounded_bytes() -> None: + class Loopback: + contract_version = 1 + + async def exchange(self, packet: bytes, *, maximum_bytes: int) -> bytes: + assert len(packet) <= maximum_bytes + return packet + + packet = b"canonical-public-identity" + assert await exchange_identity(Loopback(), packet) == packet + + +@pytest.mark.asyncio +async def test_none_and_threshold_approval_preserve_exact_request() -> None: + none = Approval.none() + request = ApprovalRequest( + "request-1", + "action", + bytes([4]) * 32, + none.policy.reference, + 100, + (), + ) + response = await none.provider.approve(request) + assert response.decision == "approved" + assert none.policy.mode == "none" + + class Provider: + def __init__(self, decision: str) -> None: + self._decision = decision + + async def approve(self, value: ApprovalRequest) -> ApprovalResponse: + return ApprovalResponse( + value.request_id, + value.transaction_digest, + value.policy, + "approved" if self._decision == "approved" else "rejected", + ) + + provider = threshold_approval( + (Provider("approved"), Provider("approved"), Provider("rejected")), + threshold=2, + ) + assert (await provider.approve(request)).decision == "approved" + + +def test_http_and_application_plans_are_native_bound_and_profile_specific() -> None: + http = HttpProfile(scheme="https", authority="api.example.com") + first = http.request("GET", "/reports", query={"month": ("august",)}) + second = http.request("POST", "/reports/publish", headers={"x-mode": "safe"}) + plan = http.plan((first, second)) + review = http.review(first) + assert review.fields + assert len(review.action_commitment) == 32 + assert plan.length == 2 + assert len(plan.commitment) == 32 + with pytest.raises(HttpProfileError): + http.plan((HttpProfile(scheme="https", authority="other.example").request("GET", "/"),)) + + def canonicalize(value: str) -> CanonicalProfileAction: + return CanonicalProfileAction( + "application/json", + value.encode(), + ProfilePermission("records/update", "records://demo"), + "records://demo", + "records://service", + (ReviewField("Record", value),), + ProfileBudget("numeric-ceiling-v1", 1), + ) + + application = define_profile( + ProfileDefinition("com.example.records", 1, canonicalize, lambda value: value.body) + ) + application_plan = application.plan((application.action("one"), application.action("two"))) + assert application_plan.authority.budget == ProfileBudget("numeric-ceiling-v1", 2) + assert len(application.review(application.action("three")).action_commitment) == 32 + + +def test_typed_trust_compilation_has_no_protocol_byte_construction() -> None: + root = Principal("key:sha256:qogx823wE-Cfoq_WXwDS1D6S8jMOhJssOpaNRZOJCKs") + anchor = TrustAnchor( + "local.root", + root, + ("raw-key-v1",), + (Profile("auths.mcp", 1),), + (Permission("tools/call", "mcp://reports/tools/update_demo_record"),), + ("mcp://reports",), + ("mcp://reports",), + 0, + 100, + 2, + "raw-key-baseline", + ) + compiled = compile_trust( + anchors=(anchor,), assurance=AssurancePolicy("raw-key-baseline", ()) + ) + assert compiled.roots == (root,) + assert len(compiled.context.configuration) == 32 + + +def test_rotation_and_clean_policy_replacement_are_typed_recipes() -> None: + previous = Principal("key:sha256:qogx823wE-Cfoq_WXwDS1D6S8jMOhJssOpaNRZOJCKs") + current = Principal("key:sha256:MPL4hHxgoCRRtbEjYAedm50CmSM11XgLojSwwYeRi1E") + rotation = rotate_identity( + method="auths.status", + previous=previous, + current=current, + purpose="authentication", + issuer=previous, + previous_sequence=2, + current_sequence=1, + valid_for=60, + observed_at=10, + ) + assert rotation.previous.state == "superseded" + assert rotation.current.state == "active" + + def trusted(root: Principal, permission: str) -> CompiledTrust: + return compile_trust( + anchors=( + TrustAnchor( + "local.root", + root, + ("raw-key-v1",), + (Profile("auths.mcp", 1),), + (Permission(permission, "mcp://reports/tools/update_demo_record"),), + ("mcp://reports",), + ("mcp://reports",), + 0, + 100, + 2, + "raw-key-baseline", + ), + ), + assurance=AssurancePolicy("raw-key-baseline", ()), + ) + + replacement = replace_policy( + trusted(previous, "tools/call"), + trusted(current, "tools/admin"), + activated_at=20, + ) + assert replacement.activated_at == 20 + + +@pytest.mark.asyncio +async def test_in_memory_runtime_store_is_atomic_for_replay_and_budget() -> None: + store = InMemoryRuntimeStore(budget_ceilings={"numeric-ceiling-v1": 3}) + challenge = bytes([5]) * 32 + assert await store.issue(challenge, expires_at=100) + assert not await store.issue(challenge, expires_at=100) + assert await store.claim(challenge, now=50) == "claimed" + assert await store.claim(challenge, now=50) == "duplicate" + first = bytes([6]) * 32 + second = bytes([7]) * 32 + assert await store.reserve(first, "numeric-ceiling-v1", 2) == "reserved" + assert await store.reserve(first, "numeric-ceiling-v1", 2) == "duplicate" + assert await store.reserve(second, "numeric-ceiling-v1", 2) == "exhausted" + assert RuntimeKernel().transition( + None, + "record-decision", + TransitionGates( + core_authorized=True, + policy_eligible=True, + configuration_matches=True, + not_revoked=True, + not_expired=True, + ), + ).kind == "applied" + + +def test_observability_is_bounded_redacted_and_deterministic() -> None: + event = AuthsEvent( + "auths.verify", + "verify", + "complete", + "authorized", + 10, + (("profile", "auths.mcp"),), + ) + timeline = DecisionTimeline() + timeline.append(event) + first = support_bundle(timeline.snapshot(), runtime={"python": "3.13"}) + second = support_bundle(timeline.snapshot(), runtime={"python": "3.13"}) + assert first == second + with pytest.raises(ValueError, match="attribute name"): + AuthsEvent("auths.verify", "verify", "complete", "denied", 10, (("proof", "secret"),)) + + +def test_runtime_diagnostic_binds_trust_and_adapter_contracts() -> None: + diagnostic = runtime_diagnostic( + trust_configuration=bytes([8]) * 32, + adapters={"custody.kms": 1, "runtime.sqlite": 1}, + ) + assert diagnostic.coherent + assert diagnostic.trust_configuration == bytes([8]) * 32 + assert diagnostic.adapters == (("custody.kms", 1), ("runtime.sqlite", 1)) + + +def test_batch_verification_preserves_single_item_meaning_and_order() -> None: + values = ( + ( + (CORPUS / "raw-key-chain.proof.cbor").read_bytes(), + (CORPUS / "raw-key-chain.action.cbor").read_bytes(), + (VECTORS / "authorized.context.cbor").read_bytes(), + ), + ( + (CORPUS / "raw-key-chain.proof.cbor").read_bytes(), + b"not-canonical-cbor", + (VECTORS / "authorized.context.cbor").read_bytes(), + ), + ) + independent = tuple(verify(*value) for value in values) + batched = verify_many(values) + assert [(value.kind, value.code) for value in batched] == [ + (value.kind, value.code) for value in independent + ] diff --git a/bindings/python/tests/test_mcp_workflow.py b/bindings/python/tests/test_mcp_workflow.py new file mode 100644 index 00000000..71bb3a7c --- /dev/null +++ b/bindings/python/tests/test_mcp_workflow.py @@ -0,0 +1,854 @@ +from __future__ import annotations + +import asyncio +import copy +import json +import pickle +from pathlib import Path +from typing import Optional +import pytest + +from auths import ( + Approval, + ApprovalRequest, + ApprovalResponse, + AttachedAgent, + AuthsClient, + AuthsWorkflowError, + BudgetCeiling, + ControlEvidence, + DelegatedAuthority, + ExpiryOnly, + Permission, + Principal, + PrincipalDescriptor, + SignedGrantMaterial, + SigningRequest, + SigningResponse, + TrustedAuthority, + Validity, + _native as native, +) +from auths.profiles.mcp import ( + AuthorizationRequest, + McpAuthorized, + McpAuthorizationResult, + McpDenied, + McpGatewayCall, + McpGatewayCancelled, + McpGatewayError, + McpIndeterminate, + McpPlanAuthorized, + McpPlanDenied, + McpProfile, + mcp, +) +from auths import _native as native_abi +from auths.diagnostics import create_diagnostic_verifier +from auths.inspection import ( + inspect_decision, + parse_signed_object, + parse_trusted_context_bytes, +) + + +VECTORS = Path(__file__).parents[3] / "target" / "binding-vectors" +ROOT = Principal("key:sha256:qogx823wE-Cfoq_WXwDS1D6S8jMOhJssOpaNRZOJCKs") +ACTOR = Principal("key:sha256:MPL4hHxgoCRRtbEjYAedm50CmSM11XgLojSwwYeRi1E") + + +class ApprovalDouble: + def __init__(self) -> None: + self.calls = 0 + + async def approve(self, request: ApprovalRequest) -> ApprovalResponse: + self.calls += 1 + return ApprovalResponse( + request.request_id, + request.transaction_digest, + request.policy, + "approved", + ) + + +class ActionSigner: + kind = "test-action" + lifecycle = "durable" + + def __init__( + self, + signature_file: str, + *, + evidence: bool = True, + principal: Principal = ACTOR, + evidence_file: str = "mcp.actor-evidence.bin", + grant_signature_file: Optional[str] = None, + lifecycle: str = "durable", + ) -> None: + self._signature = (VECTORS / signature_file).read_bytes() + self._grant_signature = ( + None + if grant_signature_file is None + else (VECTORS / grant_signature_file).read_bytes() + ) + self._evidence = evidence + self._principal = principal + self._evidence_file = evidence_file + self.lifecycle = lifecycle + self.closed = False + self.signatures = 0 + + async def public_identity(self) -> PrincipalDescriptor: + return PrincipalDescriptor( + self._principal, + "raw-key-v1", + self._principal.value, + "ed25519-v1", + ) + + async def sign(self, request: SigningRequest) -> SigningResponse: + self.signatures += 1 + evidence = ( + ( + ControlEvidence( + "raw-key-v1", + "application/vnd.auths.raw-key.v1", + (VECTORS / self._evidence_file).read_bytes(), + ), + ) + if self._evidence + else () + ) + return SigningResponse( + request.request_id, + request.principal, + request.transaction_digest, + ( + self._grant_signature + if request.object_kind == "grant" and self._grant_signature is not None + else self._signature + ), + evidence, + ) + + async def aclose(self) -> None: + self.closed = True + + +class SequenceSigner(ActionSigner): + def __init__(self, signature_files: tuple[str, ...]) -> None: + super().__init__(signature_files[0]) + self._signatures = tuple( + (VECTORS / signature_file).read_bytes() + for signature_file in signature_files + ) + + async def sign(self, request: SigningRequest) -> SigningResponse: + index = self.signatures + self._signature = self._signatures[index] + return await super().sign(request) + + +def context() -> native.TrustedContext: + return parse_trusted_context_bytes((VECTORS / "mcp.context.cbor").read_bytes()) + + +def root_material() -> SignedGrantMaterial: + return SignedGrantMaterial( + parse_signed_object( + "grant", (VECTORS / "mcp.signed-root-grant.cbor").read_bytes() + ), + ( + ControlEvidence( + "raw-key-v1", + "application/vnd.auths.raw-key.v1", + (VECTORS / "mcp.root-evidence.bin").read_bytes(), + ), + ), + ) + + +async def authorize( + signature_file: str = "mcp.action-signature.bin", *, evidence: bool = True +) -> tuple[AuthsClient, McpProfile, AttachedAgent, McpAuthorizationResult]: + signer = ActionSigner(signature_file, evidence=evidence) + approval = Approval.every_action("approval.mcp", ApprovalDouble()) + profile = mcp.profile(service="reports") + client = AuthsClient( + signer=signer, + trusted_authority=TrustedAuthority( + "local.mcp-root", + ROOT, + context(), + approval.policy.reference, + ), + ) + await client.open() + agent = await client.attach_agent( + name="reports-agent", + profile=profile, + authority=root_material(), + approval=approval, + ) + action = profile.call("update_demo_record", {"value": "reviewed"}) + result = await agent.authorize( + action, + request=AuthorizationRequest(bytes([0x22]) * 32, 50), + ) + return client, profile, agent, result + + +def test_mcp_review_is_available_before_approval() -> None: + profile = mcp.profile(service="reports") + action = profile.call("update_demo_record", {"value": "reviewed"}) + review = profile.review(action) + assert review.title + assert review.fields + assert len(review.action_commitment) == 32 + + +@pytest.mark.asyncio +async def test_installed_workflow_authorizes_and_executes_one_native_command() -> None: + client, profile, _, result = await authorize() + assert isinstance(result, McpAuthorized) + calls: list[McpGatewayCall] = [] + + async def execute(call: McpGatewayCall) -> str: + calls.append(call) + return "updated" + + gateway = profile.gateway(execute) + response, receipt = await gateway.execute( + result.command, idempotency_key="request-1" + ) + assert response == "updated" + assert receipt.command_commitment == result.action_commitment + assert len(receipt.authority_commitment) == 32 + assert len(receipt.context_commitment) == 32 + assert receipt.plan_commitment is None + assert receipt.state_claim == "committed" + assert receipt.outcome == "succeeded" + assert calls == [ + McpGatewayCall("reports", "update_demo_record", b'{"value":"reviewed"}') + ] + with pytest.raises(RuntimeError, match="consumed"): + await gateway.execute(result.command, idempotency_key="request-1") + await client.aclose() + + +@pytest.mark.asyncio +async def test_installed_workflow_delegates_authorizes_and_executes() -> None: + child_principal = Principal( + (VECTORS / "mcp.child-principal.txt").read_text().strip() + ) + parent_signer = ActionSigner( + "mcp.action-signature.bin", + grant_signature_file="mcp.child-grant-signature.bin", + ) + child_signer = ActionSigner( + "mcp.child-action-signature.bin", + principal=child_principal, + evidence_file="mcp.child-evidence.bin", + lifecycle="ephemeral", + ) + approval = Approval.every_action("approval.mcp", ApprovalDouble()) + profile = mcp.profile(service="reports") + client = AuthsClient( + signer=parent_signer, + trusted_authority=TrustedAuthority( + "local.mcp-root", ROOT, context(), approval.policy.reference + ), + ) + calls: list[McpGatewayCall] = [] + + async def execute(call: McpGatewayCall) -> str: + calls.append(call) + return "delegated-update" + + async with client: + parent = await client.attach_agent( + name="reports-agent", + profile=profile, + authority=root_material(), + approval=approval, + ) + async with await parent.delegate( + name="reports-child", + authority=DelegatedAuthority( + permissions=( + Permission("tools/call", "mcp://reports/tools/update_demo_record"), + ), + validity=Validity(30, 70), + audiences=("mcp://reports",), + remaining_depth=0, + budget=BudgetCeiling("numeric-ceiling-v1", 10), + status=ExpiryOnly(), + ), + signer=child_signer, + ) as child: + result = await child.authorize( + profile.call("update_demo_record", {"value": "reviewed"}), + request=AuthorizationRequest(bytes([0x22]) * 32, 50), + ) + assert isinstance(result, McpAuthorized) + assert ( + await profile.gateway(execute).execute( + result.command, idempotency_key="request-child" + ) + )[0] == "delegated-update" + assert child_signer.closed + assert calls == [ + McpGatewayCall("reports", "update_demo_record", b'{"value":"reviewed"}') + ] + + +@pytest.mark.asyncio +async def test_denied_and_indeterminate_results_cannot_reach_the_gateway() -> None: + signer = ActionSigner("mcp.denied-action-signature.bin") + approval = Approval.every_action("approval.mcp", ApprovalDouble()) + profile = mcp.profile(service="reports") + client = AuthsClient( + signer=signer, + trusted_authority=TrustedAuthority( + "local.mcp-root", ROOT, context(), approval.policy.reference + ), + ) + async with client: + agent = await client.attach_agent( + name="reports-agent", + profile=profile, + authority=root_material(), + approval=approval, + ) + denied = await agent.authorize( + profile.call("delete_demo_record", {"value": "reviewed"}), + request=AuthorizationRequest(bytes([0x22]) * 32, 50), + ) + assert isinstance(denied, McpDenied) + assert not hasattr(denied, "command") + indeterminate_client, _, _, indeterminate = await authorize(evidence=False) + assert isinstance(indeterminate, McpIndeterminate) + assert not hasattr(indeterminate, "command") + await indeterminate_client.aclose() + + +@pytest.mark.asyncio +async def test_gateway_rejects_wrong_profile_without_consuming_command() -> None: + client, profile, _, result = await authorize() + assert isinstance(result, McpAuthorized) + calls = 0 + + async def execute(_call: McpGatewayCall) -> None: + nonlocal calls + calls += 1 + + with pytest.raises(TypeError, match="native MCP command"): + await profile.gateway(execute).execute( # type: ignore[arg-type] + b"forged", idempotency_key="forged" + ) + with pytest.raises(TypeError, match="does not belong"): + await mcp.profile(service="billing").gateway(execute).execute( + result.command, idempotency_key="wrong-profile" + ) + assert calls == 0 + await profile.gateway(execute).execute(result.command, idempotency_key="right-profile") + assert calls == 1 + await client.aclose() + + +@pytest.mark.asyncio +async def test_native_command_cannot_be_forged_copied_or_serialized() -> None: + client, _, _, result = await authorize() + assert isinstance(result, McpAuthorized) + with pytest.raises(TypeError): + type(result.command)() + with pytest.raises(TypeError): + type("ForgedMcpCommand", (type(result.command),), {}) + with pytest.raises(AttributeError): + result.command.name = "substituted" # type: ignore[misc] + with pytest.raises(TypeError): + memoryview(result.command) + for operation in ( + lambda: copy.copy(result.command), + lambda: copy.deepcopy(result.command), + lambda: pickle.dumps(result.command), + ): + with pytest.raises(TypeError, match="non-copyable"): + operation() + await client.aclose() + + +@pytest.mark.asyncio +async def test_duplicate_native_command_handle_fails_without_consumption() -> None: + client, profile, _, result = await authorize() + assert isinstance(result, McpAuthorized) + action = profile.call("update_demo_record", {"value": "reviewed"}) + plan = profile.plan((action, action)) + + with pytest.raises(ValueError, match="duplicate command handle"): + native_abi.seal_mcp_plan_command( + [result.command, result.command], + "reports", + plan.commitment, + ) + + calls = 0 + + async def execute(_call: McpGatewayCall) -> None: + nonlocal calls + calls += 1 + + await profile.gateway(execute).execute(result.command, idempotency_key="unique-plan") + assert calls == 1 + await client.aclose() + + +@pytest.mark.asyncio +async def test_profile_mismatch_and_gateway_failure_are_closed() -> None: + client, profile, agent, result = await authorize() + assert isinstance(result, McpAuthorized) + other = mcp.profile(service="reports") + with pytest.raises(AuthsWorkflowError, match="different profile instance"): + await agent.authorize( + other.call("update_demo_record", {"value": "reviewed"}), + request=AuthorizationRequest(bytes([0x22]) * 32, 50), + ) + + async def fail(_call: McpGatewayCall) -> None: + raise RuntimeError("secret endpoint detail") + + with pytest.raises(McpGatewayError) as failure: + await profile.gateway(fail).execute(result.command, idempotency_key="failure") + assert failure.value.code == "gateway-failed" + assert failure.value.receipt.state_claim == "outcome-unknown" + assert failure.value.receipt.outcome == "outcome-unknown" + assert "secret endpoint detail" not in str(failure.value) + with pytest.raises(RuntimeError, match="consumed"): + await profile.gateway(fail).execute(result.command, idempotency_key="failure") + await client.aclose() + + +@pytest.mark.asyncio +async def test_gateway_cancellation_consumes_command_and_requires_reconciliation() -> None: + client, profile, _, result = await authorize() + assert isinstance(result, McpAuthorized) + entered = asyncio.Event() + + async def block(_call: McpGatewayCall) -> None: + entered.set() + await asyncio.Event().wait() + + operation = asyncio.create_task( + profile.gateway(block).execute(result.command, idempotency_key="cancelled") + ) + await entered.wait() + operation.cancel() + + with pytest.raises(McpGatewayCancelled) as failure: + await operation + assert failure.value.receipt.outcome == "cancelled" + assert failure.value.receipt.state_claim == "outcome-unknown" + assert failure.value.receipt.command_commitment == result.action_commitment + with pytest.raises(RuntimeError, match="consumed"): + await profile.gateway(block).execute( + result.command, idempotency_key="cancelled" + ) + await client.aclose() + + +async def plan_fixture( + signer: ActionSigner, approval_provider: ApprovalDouble +) -> tuple[AuthsClient, McpProfile, AttachedAgent]: + approval = Approval.plan_once("approval.mcp-plan", approval_provider, max_uses=2) + profile = mcp.profile(service="reports") + client = AuthsClient( + signer=signer, + trusted_authority=TrustedAuthority( + "local.mcp-root", ROOT, context(), approval.policy.reference + ), + ) + await client.open() + agent = await client.attach_agent( + name="reports-plan-agent", + profile=profile, + authority=root_material(), + approval=approval, + ) + return client, profile, agent + + +@pytest.mark.asyncio +async def test_ordered_plan_prompts_once_and_releases_one_native_plan_command() -> None: + signer = SequenceSigner(("mcp.action-signature.bin", "mcp.action-signature.bin")) + provider = ApprovalDouble() + client, profile, agent = await plan_fixture(signer, provider) + first = profile.call("update_demo_record", {"value": "reviewed"}) + second = profile.call("update_demo_record", {"value": "reviewed"}) + plan = profile.plan((first, second)) + requests = ( + AuthorizationRequest(bytes([0x22]) * 32, 50), + AuthorizationRequest(bytes([0x22]) * 32, 50), + ) + + result = await agent.authorize_plan(plan, requests=requests) + + assert isinstance(result, McpPlanAuthorized) + assert result.command.count == 2 + assert result.command.plan_commitment == plan.commitment + assert len(result.results) == 2 + assert all(not hasattr(member, "command") for member in result.results) + assert provider.calls == 1 + assert signer.signatures == 2 + assert plan.length == 2 + assert plan.authority.resource_namespaces == ("mcp://reports",) + + for operation in ( + lambda: copy.copy(result.command), + lambda: copy.deepcopy(result.command), + lambda: pickle.dumps(result.command), + ): + with pytest.raises(TypeError, match="native capability"): + operation() + with pytest.raises(TypeError): + type(result.command)() + with pytest.raises(TypeError): + type("ForgedMcpPlanCommand", (type(result.command),), {}) + with pytest.raises(AttributeError): + result.command.plan_commitment = bytes(32) # type: ignore[misc] + with pytest.raises(TypeError): + memoryview(result.command) + + calls: list[McpGatewayCall] = [] + + async def execute(call: McpGatewayCall) -> str: + calls.append(call) + return call.name + + gateway = profile.gateway(execute) + responses, receipts = await gateway.execute_plan( + result.command, idempotency_key="plan-request" + ) + assert responses == ( + "update_demo_record", + "update_demo_record", + ) + assert len(receipts) == 2 + assert all(receipt.plan_commitment == plan.commitment for receipt in receipts) + assert tuple(receipt.command_commitment for receipt in receipts) == tuple( + member.action_commitment for member in result.results + ) + assert all(len(receipt.authority_commitment) == 32 for receipt in receipts) + assert all(len(receipt.context_commitment) == 32 for receipt in receipts) + assert len(calls) == 2 + with pytest.raises(RuntimeError, match="consumed"): + await gateway.execute_plan(result.command, idempotency_key="plan-request") + await client.aclose() + + +@pytest.mark.asyncio +async def test_plan_gateway_failure_reports_completed_and_uncertain_members() -> None: + signer = SequenceSigner(("mcp.action-signature.bin", "mcp.action-signature.bin")) + provider = ApprovalDouble() + client, profile, agent = await plan_fixture(signer, provider) + action = profile.call("update_demo_record", {"value": "reviewed"}) + plan = profile.plan((action, action)) + result = await agent.authorize_plan( + plan, + requests=( + AuthorizationRequest(bytes([0x22]) * 32, 50), + AuthorizationRequest(bytes([0x22]) * 32, 50), + ), + ) + assert isinstance(result, McpPlanAuthorized) + calls = 0 + + async def fail_second(_call: McpGatewayCall) -> str: + nonlocal calls + calls += 1 + if calls == 2: + raise RuntimeError("provider response was lost") + return "updated" + + with pytest.raises(McpGatewayError) as failure: + await profile.gateway(fail_second).execute_plan( + result.command, idempotency_key="partial" + ) + assert len(failure.value.completed_receipts) == 1 + assert failure.value.completed_receipts[0].outcome == "succeeded" + assert failure.value.completed_receipts[0].state_claim == "committed" + assert failure.value.receipt.idempotency_key == "partial:1" + assert failure.value.receipt.outcome == "outcome-unknown" + assert failure.value.receipt.state_claim == "outcome-unknown" + assert failure.value.receipt.plan_commitment == plan.commitment + assert calls == 2 + await client.aclose() + + +def test_mcp_plan_commitment_binds_order_and_exact_membership() -> None: + profile = mcp.profile(service="reports") + first = profile.call("update_demo_record", {"value": "first"}) + second = profile.call("update_demo_record", {"value": "second"}) + + ordered = profile.plan((first, second)) + reordered = profile.plan((second, first)) + duplicated = profile.plan((first, first)) + + assert ordered.commitment != reordered.commitment + assert ordered.commitment != duplicated.commitment + + +@pytest.mark.asyncio +async def test_plan_approval_response_substitution_fails_before_signing() -> None: + class SubstitutingApproval(ApprovalDouble): + async def approve(self, request: ApprovalRequest) -> ApprovalResponse: + response = await super().approve(request) + return ApprovalResponse( + response.request_id, + bytes(32), + response.policy, + response.decision, + ) + + signer = SequenceSigner(("mcp.action-signature.bin", "mcp.action-signature.bin")) + provider = SubstitutingApproval() + client, profile, agent = await plan_fixture(signer, provider) + plan = profile.plan( + ( + profile.call("update_demo_record", {"value": "reviewed"}), + profile.call("update_demo_record", {"value": "reviewed"}), + ) + ) + + with pytest.raises(AuthsWorkflowError) as failure: + await agent.authorize_plan( + plan, + requests=( + AuthorizationRequest(bytes([0x22]) * 32, 50), + AuthorizationRequest(bytes([0x22]) * 32, 50), + ), + ) + assert failure.value.code == "approval-rejected" + assert signer.signatures == 0 + assert provider.calls == 1 + await client.aclose() + + +@pytest.mark.asyncio +async def test_plan_cancellation_exposes_no_partial_command() -> None: + class BlockingSecondSigner(SequenceSigner): + def __init__(self) -> None: + super().__init__(("mcp.action-signature.bin", "mcp.action-signature.bin")) + self.started = asyncio.Event() + + async def sign(self, request: SigningRequest) -> SigningResponse: + if self.signatures == 1: + self.started.set() + await asyncio.Event().wait() + return await super().sign(request) + + signer = BlockingSecondSigner() + provider = ApprovalDouble() + client, profile, agent = await plan_fixture(signer, provider) + action = profile.call("update_demo_record", {"value": "reviewed"}) + operation = asyncio.create_task( + agent.authorize_plan( + profile.plan((action, action)), + requests=( + AuthorizationRequest(bytes([0x22]) * 32, 50), + AuthorizationRequest(bytes([0x22]) * 32, 50), + ), + ) + ) + + await signer.started.wait() + operation.cancel() + with pytest.raises(asyncio.CancelledError): + await operation + assert provider.calls == 1 + assert signer.signatures == 1 + await client.aclose() + + +@pytest.mark.asyncio +async def test_ordered_plan_failure_exposes_no_partial_command() -> None: + signer = SequenceSigner( + ("mcp.action-signature.bin", "mcp.denied-action-signature.bin") + ) + provider = ApprovalDouble() + client, profile, agent = await plan_fixture(signer, provider) + plan = profile.plan( + ( + profile.call("update_demo_record", {"value": "reviewed"}), + profile.call("delete_demo_record", {"value": "reviewed"}), + ) + ) + + result = await agent.authorize_plan( + plan, + requests=( + AuthorizationRequest(bytes([0x22]) * 32, 50), + AuthorizationRequest(bytes([0x22]) * 32, 50), + ), + ) + + assert isinstance(result, McpPlanDenied) + assert result.failed_index == 1 + assert len(result.results) == 2 + assert all(not hasattr(member, "command") for member in result.results) + assert not hasattr(result, "command") + assert provider.calls == 1 + assert signer.signatures == 2 + await client.aclose() + + +@pytest.mark.asyncio +async def test_plan_mutation_after_approval_fails_before_the_next_signature() -> None: + signer = SequenceSigner(("mcp.action-signature.bin", "mcp.action-signature.bin")) + provider = ApprovalDouble() + client, profile, agent = await plan_fixture(signer, provider) + first = profile.call("update_demo_record", {"value": "reviewed"}) + second = profile.call("delete_demo_record", {"value": "reviewed"}) + plan = profile.plan((first, second)) + original_approve = provider.approve + + async def mutate(request: ApprovalRequest) -> ApprovalResponse: + second._call = first._call + return await original_approve(request) + + provider.approve = mutate # type: ignore[method-assign] + + with pytest.raises(AuthsWorkflowError, match="membership changed"): + await agent.authorize_plan( + plan, + requests=( + AuthorizationRequest(bytes([0x22]) * 32, 50), + AuthorizationRequest(bytes([0x22]) * 32, 50), + ), + ) + assert provider.calls == 1 + assert signer.signatures == 1 + await client.aclose() + + +@pytest.mark.asyncio +async def test_decision_inspection_and_diagnostic_verification_stay_inert() -> None: + client, profile, _, result = await authorize() + assert isinstance(result, McpAuthorized) + + inspection = inspect_decision(result) + assert inspection.decision.kind == "authorized" + assert inspection.kernel.code == "authorized" + assert inspection.commitments.action == result.action_commitment + assert len(inspection.commitments.result) == 32 + assert set(inspection.safe_to_log) == {"kind", "stage", "code", "retryable"} + assert not hasattr(inspection, "command") + + class Engine: + def verify_v1( + self, proof_cbor: bytes, action_cbor: bytes, context_cbor: bytes + ) -> bytes: + assert proof_cbor == b"proof" + assert action_cbor == b"action" + assert context_cbor == b"context" + return result.result_cbor + + diagnostic = create_diagnostic_verifier(Engine()).verify( + b"proof", b"action", b"context" + ) + assert diagnostic.kind == "authorized" + assert diagnostic.effect_capable is False + assert diagnostic.submitted_action_cbor == b"action" + assert not hasattr(diagnostic, "action") + assert not hasattr(diagnostic, "command") + + async def execute(_call: McpGatewayCall) -> None: + return None + + gateway = profile.gateway(execute) + with pytest.raises(TypeError): + await gateway.execute(inspection) # type: ignore[arg-type] + with pytest.raises(TypeError): + await gateway.execute(diagnostic) # type: ignore[arg-type] + await client.aclose() + + +def test_shared_full_workflow_projection_matches_native_python() -> None: + projection = json.loads((VECTORS / "workflow.projection.json").read_text()) + result = native_abi.verify_v1( + (VECTORS / "workflow.proof.cbor").read_bytes(), + (VECTORS / "workflow.action.cbor").read_bytes(), + (VECTORS / "workflow.context.cbor").read_bytes(), + ) + + assert projection["schema"] == "auths.full-workflow-projection/1" + assert (result.kind, result.stage, result.code) == ( + projection["verdict"], + projection["stage"], + projection["code"], + ) + assert list(result.metrics) == list(projection["metrics"].values()) + assert bytes(result.result_cbor) == (VECTORS / "workflow.result.cbor").read_bytes() + assert ( + native_abi.commit_canonical_v1( + "auths.canonical-action.v1", + (VECTORS / "workflow.action.cbor").read_bytes(), + ).hex() + == projection["commitments"]["action"] + ) + assert ( + native_abi.commit_canonical_v1( + "auths.verification-result.v1", bytes(result.result_cbor) + ).hex() + == projection["commitments"]["result"] + ) + assert ( + native_abi.commit_canonical_v1( + "auths.verifier-configuration.v1", bytes(result.local_configuration) + ).hex() + == projection["commitments"]["localConfiguration"] + ) + + call = native_abi.mcp_call( + projection["command"]["service"], + projection["command"]["name"], + projection["command"]["argumentsJson"].encode(), + ) + plan = native_abi.commit_mcp_plan([call, call]) + assert bytes(plan.commitment).hex() == projection["commitments"]["plan"] + assert [bytes(member).hex() for member in plan.members] == projection[ + "commitments" + ]["planMembers"] + assert ( + plan.permissions + == [("tools/call", "mcp://reports/tools/update_demo_record")] * 2 + ) + assert plan.resource_namespaces == ["mcp://reports"] + assert plan.audiences == ["mcp://reports"] + assert ( + native_abi.commit_plan_approval( + bytes(plan.commitment), bytes([7]) * 32, 2, 350 + ).hex() + == projection["commitments"]["planApproval"] + ) + + parent = parse_signed_object( + "grant", (VECTORS / "mcp.signed-root-grant.cbor").read_bytes() + ) + proposed = parse_signed_object( + "grant", (VECTORS / "mcp.signed-child-grant.cbor").read_bytes() + ) + diff = native_abi.plan_child_statement( + native_abi.unsigned_from_signed(parent), + native_abi.grant_request_from_statement( + native_abi.unsigned_from_signed(proposed) + ), + ).diff + assert { + "removedPermissions": diff.removed_permissions, + "removedAudiences": diff.removed_audiences, + "validityShortened": diff.validity_shortened, + "actionNarrowed": diff.action_narrowed, + "budgetNarrowed": diff.budget_narrowed, + "statusNarrowed": diff.status_narrowed, + "delegationDepth": list(diff.delegation_depth), + } == projection["authorityDiff"] diff --git a/bindings/python/tests/test_native_authoring.py b/bindings/python/tests/test_native_authoring.py new file mode 100644 index 00000000..8915aba3 --- /dev/null +++ b/bindings/python/tests/test_native_authoring.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from auths import _native as native +from auths.inspection import ( + mcp_action_bytes, + parse_signed_object, + parse_unsigned_object, + signed_object_bytes, + trusted_context_bytes, + unsigned_object_bytes, +) + + +VECTORS = Path(__file__).parents[3] / "target" / "binding-vectors" +ROOT = native.Principal("key:sha256:qogx823wE-Cfoq_WXwDS1D6S8jMOhJssOpaNRZOJCKs") +ACTOR = native.Principal("key:sha256:MPL4hHxgoCRRtbEjYAedm50CmSM11XgLojSwwYeRi1E") + + +def test_native_abi_is_explicitly_versioned() -> None: + assert native.native_abi_version() == 2 + assert len(native.self_contained_configuration()) == 32 + + +def test_child_planning_matches_the_shared_rust_typescript_fixture() -> None: + parent = parse_unsigned_object( + "grant", (VECTORS / "authoring.parent-grant.cbor").read_bytes() + ) + proposed = parse_unsigned_object( + "grant", (VECTORS / "authoring.proposed-grant.cbor").read_bytes() + ) + + plan = native.plan_child_statement( + parent, native.grant_request_from_statement(proposed) + ) + + assert ( + unsigned_object_bytes(plan.unsigned) + == (VECTORS / "authoring.planned-grant.cbor").read_bytes() + ) + assert plan.diff.delegation_depth == (1, 0) + assert plan.diff.budget_narrowed + + +def test_exact_signing_request_is_native_owned_and_single_use() -> None: + unsigned = parse_unsigned_object( + "grant", (VECTORS / "authoring.proposed-grant.cbor").read_bytes() + ) + request = native.prepare_signing( + unsigned, + "raw-key-v1", + ROOT.value, + "ed25519-v1", + ) + + assert request.object_kind == "grant" + assert request.request_id.startswith("grant:") + assert len(request.object_id) == 32 + assert len(request.transaction_digest) == 32 + assert request.signing_preimage + + signed = request.complete(bytes(64)) + assert signed.kind == "grant" + with pytest.raises(RuntimeError, match="already completed"): + request.complete(bytes(64)) + assert signed_object_bytes( + parse_signed_object("grant", signed_object_bytes(signed)) + ) == signed_object_bytes(signed) + + +def test_mcp_profile_semantics_are_owned_by_rust() -> None: + terminal = parse_signed_object( + "grant", (VECTORS / "mcp.signed-root-grant.cbor").read_bytes() + ) + + action = native.prepare_mcp_action( + "reports", + "update_demo_record", + b'{"value":"reviewed"}', + ACTOR, + terminal, + bytes([0x22]) * 32, + 50, + ) + canonical, arguments = mcp_action_bytes(action) + + assert action.audience == "mcp://reports" + assert action.resource == "mcp://reports/tools/update_demo_record" + assert len(action.display_digest_hex) == 64 + assert canonical + assert arguments == b'{"value":"reviewed"}' + assert action.unsigned.kind == "action" + + with pytest.raises(ValueError, match="canonical JSON"): + native.prepare_mcp_action( + "reports", + "update_demo_record", + b'{"value": "reviewed"}', + ACTOR, + terminal, + bytes([0x22]) * 32, + 50, + ) + + +def test_trust_compilation_and_request_binding_stay_native() -> None: + assurance = native.AssurancePolicy( + "raw-key-baseline", + [ + ("root", "every", "self-certifying-identifier", None), + ("actor", "every", "self-certifying-identifier", None), + ], + ) + anchor = native.TrustAnchor( + ROOT.value, + ROOT, + ["raw-key-v1"], + [("auths.mcp", 1)], + [("tools/call", "mcp://reports/tools/update_demo_record")], + ["mcp://reports"], + ["mcp://reports"], + 0, + 100, + ("numeric-ceiling-v1", 20), + 1, + "raw-key-baseline", + None, + ) + template = native.compile_trusted_context( + native.self_contained_configuration(), + None, + 1, + 1, + 1, + [anchor], + assurance, + None, + None, + "none-v1", + ["raw-key-v1"], + [], + ) + request = template.bind_request("mcp://reports", bytes([0x22]) * 32, 50) + + assert request.configuration == native.self_contained_configuration() + assert trusted_context_bytes(request) + + +def test_authorization_plan_builder_is_typed_and_bounded() -> None: + builder = native.AuthorizationPlanBuilder() + first = builder.proof(bytes([1]) * 32) + second = builder.proof(bytes([2]) * 32) + plan = builder.threshold(1, [first, second]) + + assert plan.shape == (2, 2) + assert len(plan.plan_id) == 32 diff --git a/bindings/python/tests/test_workflow.py b/bindings/python/tests/test_workflow.py new file mode 100644 index 00000000..eb2f5b17 --- /dev/null +++ b/bindings/python/tests/test_workflow.py @@ -0,0 +1,886 @@ +from __future__ import annotations + +import asyncio +from dataclasses import replace +from pathlib import Path +from typing import Callable, Optional + +import pytest + +from auths import ( + AnyBody, + Approval, + ApprovalRequest, + ApprovalResponse, + AuthsClient, + AuthsWorkflowError, + BudgetCeiling, + DelegatedAuthority, + ExpiryOnly, + Permission, + Principal, + PrincipalDescriptor, + Profile, + ProviderOperationError, + SignedGrantLoadRequest, + SignedGrantMaterial, + SignedGrantSource, + SigningRequest, + SigningResponse, + SnapshotRequired, + TrustedAuthority, + Validity, + _native as native, +) +from auths.inspection import parse_signed_object, parse_unsigned_object + + +VECTORS = Path(__file__).parents[3] / "target" / "binding-vectors" +ROOT = Principal("key:sha256:qogx823wE-Cfoq_WXwDS1D6S8jMOhJssOpaNRZOJCKs") +PARENT = Principal("key:sha256:MPL4hHxgoCRRtbEjYAedm50CmSM11XgLojSwwYeRi1E") +CHILD = Principal("key:sha256:8C7PrA0wN6L7dI7ZmbYVYnR1Q21dzCq6FjDEHNt58fA") +PROFILE = Profile("auths.mcp", 1) + + +class ApprovalDouble: + def __init__(self) -> None: + self.calls = 0 + self.mutate: Optional[Callable[[ApprovalRequest], ApprovalResponse]] = None + self.started: Optional[asyncio.Event] = None + self.release: Optional[asyncio.Event] = None + + async def approve(self, request: ApprovalRequest) -> ApprovalResponse: + self.calls += 1 + if self.started is not None: + self.started.set() + if self.release is not None: + await self.release.wait() + if self.mutate is not None: + return self.mutate(request) + return ApprovalResponse( + request.request_id, + request.transaction_digest, + request.policy, + "approved", + ) + + +class SignerDouble: + def __init__( + self, + principal: Principal, + *, + kind: str, + lifecycle: str, + ) -> None: + self.kind = kind + self.lifecycle = lifecycle + self.descriptor = PrincipalDescriptor( + principal, + "raw-key-v1", + principal.value, + "ed25519-v1", + ) + self.identity_calls = 0 + self.sign_calls = 0 + self.close_calls = 0 + self.mutate: Optional[Callable[[SigningRequest], SigningResponse]] = None + self.identity_started: Optional[asyncio.Event] = None + self.identity_release: Optional[asyncio.Event] = None + self.sign_started: Optional[asyncio.Event] = None + self.sign_release: Optional[asyncio.Event] = None + + async def public_identity(self) -> PrincipalDescriptor: + self.identity_calls += 1 + if self.identity_started is not None: + self.identity_started.set() + if self.identity_release is not None: + await self.identity_release.wait() + return self.descriptor + + async def sign(self, request: SigningRequest) -> SigningResponse: + self.sign_calls += 1 + if self.sign_started is not None: + self.sign_started.set() + if self.sign_release is not None: + await self.sign_release.wait() + if self.mutate is not None: + return self.mutate(request) + return SigningResponse( + request.request_id, + request.principal, + request.transaction_digest, + bytes([9]) * 64, + ) + + async def aclose(self) -> None: + self.close_calls += 1 + + +def trusted_context() -> native.TrustedContext: + assurance = native.AssurancePolicy( + "raw-key-baseline", + [ + ("root", "every", "self-certifying-identifier", None), + ("actor", "every", "self-certifying-identifier", None), + ], + ) + anchor = native.TrustAnchor( + ROOT.value, + ROOT, + ["raw-key-v1"], + [(PROFILE.id, PROFILE.version)], + [("tools/call", "mcp://reports/read")], + ["mcp://reports"], + ["mcp://reports"], + 0, + 100, + ("numeric-ceiling-v1", 20), + 2, + "raw-key-baseline", + None, + ) + return native.compile_trusted_context( + native.self_contained_configuration(), + None, + 1, + 1, + 1, + [anchor], + assurance, + None, + None, + "none-v1", + ["raw-key-v1"], + ["extension.test-v1"], + ) + + +def signed_root( + name: str = "authoring.delegation-root-grant.cbor", +) -> native.SignedObject: + return parse_signed_object("grant", (VECTORS / name).read_bytes()) + + +def base_authority() -> DelegatedAuthority: + return DelegatedAuthority( + permissions=(Permission("tools/call", "mcp://reports/read"),), + validity=Validity(20, 80), + audiences=("mcp://reports",), + remaining_depth=1, + budget=BudgetCeiling("numeric-ceiling-v1", 10), + status=SnapshotRequired("status.test-v1", 30), + ) + + +def workflow_fixture( + *, approval_provider: Optional[ApprovalDouble] = None +) -> tuple[ + AuthsClient, + SignerDouble, + SignerDouble, + ApprovalDouble, + object, +]: + provider = approval_provider or ApprovalDouble() + approval = Approval.grant_only("approval.default", provider) + parent_signer = SignerDouble( + PARENT, + kind="test-parent", + lifecycle="durable", + ) + child_signer = SignerDouble( + CHILD, + kind="test-child", + lifecycle="ephemeral", + ) + client = AuthsClient( + signer=parent_signer, + trusted_authority=TrustedAuthority( + "local.test-root", + ROOT, + trusted_context(), + approval.policy.reference, + ), + ) + return client, parent_signer, child_signer, provider, approval + + +def test_attach_and_delegate_use_native_authority_without_protocol_bytes() -> None: + async def scenario() -> None: + client, parent_signer, child_signer, provider, approval = workflow_fixture() + async with client: + parent = await client.attach_agent( + name="research-agent", + profile=PROFILE, + authority=signed_root(), + approval=approval, # type: ignore[arg-type] + ) + assert parent.authority.issuer.value == ROOT.value + assert parent.authority.subject.value == PARENT.value + assert ( + parent.authority.explanation.code == "root-authority-structurally-bound" + ) + async with await parent.delegate( + name="records-child", + authority=base_authority(), + signer=child_signer, # type: ignore[arg-type] + ) as child: + assert child.identity.principal.principal.value == CHILD.value + assert child.authority.issuer.value == PARENT.value + assert child.authority.subject.value == CHILD.value + assert child.authority.critical_extensions == ("extension.test-v1",) + assert child.delegation is not None + assert child.delegation.diff.delegation_depth == (2, 1) + assert child.delegation.warnings == ("any-body", "delegation-allowed") + assert child_signer.close_calls == 1 + assert parent_signer.close_calls == 1 + assert provider.calls == 1 + assert parent_signer.sign_calls == 1 + assert child_signer.sign_calls == 0 + + asyncio.run(scenario()) + + +def test_attach_loads_only_typed_signed_grant_material() -> None: + class Source: + def __init__(self) -> None: + self.calls = 0 + + async def load_signed_grant( + self, request: SignedGrantLoadRequest + ) -> SignedGrantMaterial: + self.calls += 1 + assert request.subject.value == PARENT.value + assert request.profile == PROFILE + return SignedGrantMaterial(signed_root()) + + async def scenario() -> None: + client, _, _, _, approval = workflow_fixture() + source = Source() + async with client: + parent = await client.attach_agent( + name="research-agent", + profile=PROFILE, + authority=SignedGrantSource("fixture.root", source), + approval=approval, # type: ignore[arg-type] + ) + assert source.calls == 1 + await parent.aclose() + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + "authority", + [ + lambda: DelegatedAuthority( + permissions=( + Permission("tools/call", "mcp://reports/read"), + Permission("tools/admin", "mcp://reports/admin"), + ), + validity=Validity(20, 80), + audiences=("mcp://reports",), + remaining_depth=1, + ), + lambda: DelegatedAuthority( + permissions=base_authority().permissions, + validity=Validity(0, 101), + audiences=base_authority().audiences, + remaining_depth=1, + ), + lambda: DelegatedAuthority( + permissions=base_authority().permissions, + validity=base_authority().validity, + audiences=("mcp://other",), + remaining_depth=1, + ), + lambda: DelegatedAuthority( + permissions=base_authority().permissions, + validity=base_authority().validity, + audiences=base_authority().audiences, + remaining_depth=1, + budget=BudgetCeiling("numeric-ceiling-v1", 21), + ), + lambda: DelegatedAuthority( + permissions=base_authority().permissions, + validity=base_authority().validity, + audiences=base_authority().audiences, + remaining_depth=2, + ), + lambda: DelegatedAuthority( + permissions=base_authority().permissions, + validity=base_authority().validity, + audiences=base_authority().audiences, + remaining_depth=1, + status=ExpiryOnly(), + ), + lambda: DelegatedAuthority( + permissions=base_authority().permissions, + validity=base_authority().validity, + audiences=base_authority().audiences, + remaining_depth=1, + assurance_floor="weaker-policy", + ), + ], +) +def test_every_exposed_authority_dimension_fails_before_approval_when_widened( + authority: Callable[[], DelegatedAuthority], +) -> None: + async def scenario() -> None: + client, parent_signer, child_signer, provider, approval = workflow_fixture() + async with client: + parent = await client.attach_agent( + name="research-agent", + profile=PROFILE, + authority=signed_root(), + approval=approval, # type: ignore[arg-type] + ) + with pytest.raises(AuthsWorkflowError) as raised: + await parent.delegate( + name="records-child", + authority=authority(), + signer=child_signer, # type: ignore[arg-type] + ) + assert raised.value.code == "delegation-expanded" + assert provider.calls == 0 + assert parent_signer.sign_calls == 0 + assert child_signer.close_calls == 1 + + asyncio.run(scenario()) + + +def test_action_widening_fails_before_approval() -> None: + async def scenario() -> None: + client, parent_signer, child_signer, provider, approval = workflow_fixture() + async with client: + parent = await client.attach_agent( + name="research-agent", + profile=PROFILE, + authority=signed_root("authoring.signed-root-grant.cbor"), + approval=approval, # type: ignore[arg-type] + ) + request = base_authority() + with pytest.raises(AuthsWorkflowError) as raised: + await parent.delegate( + name="records-child", + authority=DelegatedAuthority( + permissions=request.permissions, + validity=request.validity, + audiences=request.audiences, + remaining_depth=0, + action_constraint=AnyBody(), + budget=request.budget, + status=request.status, + ), + signer=child_signer, # type: ignore[arg-type] + ) + assert raised.value.code == "delegation-expanded" + assert provider.calls == 0 + assert parent_signer.sign_calls == 0 + + asyncio.run(scenario()) + + +def test_approval_substitution_never_calls_the_parent_signer() -> None: + provider = ApprovalDouble() + provider.mutate = lambda request: ApprovalResponse( + request.request_id, + bytes(32), + request.policy, + "approved", + ) + + async def scenario() -> None: + client, parent_signer, child_signer, _, approval = workflow_fixture( + approval_provider=provider + ) + async with client: + parent = await client.attach_agent( + name="research-agent", + profile=PROFILE, + authority=signed_root(), + approval=approval, # type: ignore[arg-type] + ) + with pytest.raises(AuthsWorkflowError) as raised: + await parent.delegate( + name="records-child", + authority=base_authority(), + signer=child_signer, # type: ignore[arg-type] + ) + assert raised.value.code == "approval-response-mismatch" + assert provider.calls == 1 + assert parent_signer.sign_calls == 0 + assert child_signer.close_calls == 1 + + asyncio.run(scenario()) + + +def test_approval_fields_cannot_outlive_their_native_commitment() -> None: + async def scenario() -> None: + client, _, child_signer, provider, approval = workflow_fixture() + tampered = replace( + approval, + policy=replace(approval.policy, expires_in_seconds=86_400), + ) + async with client: + with pytest.raises(ValueError, match="native commitment"): + await client.attach_agent( + name="research-agent", + profile=PROFILE, + authority=signed_root(), + approval=tampered, + ) + assert provider.calls == 0 + assert child_signer.close_calls == 0 + + asyncio.run(scenario()) + + +@pytest.mark.parametrize("substitution", ["request", "policy", "decision"]) +def test_every_approval_binding_field_fails_closed(substitution: str) -> None: + provider = ApprovalDouble() + + def mutate(request: ApprovalRequest) -> ApprovalResponse: + if substitution == "request": + return ApprovalResponse( + "grant:substituted", + request.transaction_digest, + request.policy, + "approved", + ) + if substitution == "policy": + other = Approval.grant_only("approval.other", ApprovalDouble()) + return ApprovalResponse( + request.request_id, + request.transaction_digest, + other.policy.reference, + "approved", + ) + return ApprovalResponse( + request.request_id, + request.transaction_digest, + request.policy, + "rejected", + ) + + provider.mutate = mutate + + async def scenario() -> None: + client, parent_signer, child_signer, _, approval = workflow_fixture( + approval_provider=provider + ) + async with client: + parent = await client.attach_agent( + name="research-agent", + profile=PROFILE, + authority=signed_root(), + approval=approval, # type: ignore[arg-type] + ) + with pytest.raises(AuthsWorkflowError) as raised: + await parent.delegate( + name="records-child", + authority=base_authority(), + signer=child_signer, # type: ignore[arg-type] + ) + expected = ( + "approval-rejected" + if substitution == "decision" + else "approval-response-mismatch" + ) + assert raised.value.code == expected + assert parent_signer.sign_calls == 0 + assert child_signer.close_calls == 1 + + asyncio.run(scenario()) + + +def test_signer_substitution_cannot_complete_the_child_grant() -> None: + async def scenario() -> None: + client, parent_signer, child_signer, provider, approval = workflow_fixture() + parent_signer.mutate = lambda request: SigningResponse( + request.request_id, + request.principal, + bytes(32), + bytes([9]) * 64, + ) + async with client: + parent = await client.attach_agent( + name="research-agent", + profile=PROFILE, + authority=signed_root(), + approval=approval, # type: ignore[arg-type] + ) + with pytest.raises(AuthsWorkflowError) as raised: + await parent.delegate( + name="records-child", + authority=base_authority(), + signer=child_signer, # type: ignore[arg-type] + ) + assert raised.value.code == "signer-response-mismatch" + assert provider.calls == 1 + assert parent_signer.sign_calls == 1 + assert child_signer.close_calls == 1 + + asyncio.run(scenario()) + + +@pytest.mark.parametrize( + "substitution", ["request", "principal", "descriptor", "signature"] +) +def test_every_signer_binding_field_fails_closed(substitution: str) -> None: + async def scenario() -> None: + client, parent_signer, child_signer, provider, approval = workflow_fixture() + + def mutate(request: SigningRequest) -> SigningResponse: + principal = request.principal + request_id = request.request_id + signature = bytes([9]) * 64 + if substitution == "request": + request_id = "grant:substituted" + elif substitution == "principal": + principal = PrincipalDescriptor( + CHILD, "raw-key-v1", CHILD.value, "ed25519-v1" + ) + elif substitution == "descriptor": + principal = PrincipalDescriptor( + PARENT, "raw-key-v1", PARENT.value, "p256-sha256-v1" + ) + elif substitution == "signature": + signature = b"" + return SigningResponse( + request_id, + principal, + request.transaction_digest, + signature, + ) + + parent_signer.mutate = mutate + async with client: + parent = await client.attach_agent( + name="research-agent", + profile=PROFILE, + authority=signed_root(), + approval=approval, # type: ignore[arg-type] + ) + with pytest.raises(AuthsWorkflowError) as raised: + await parent.delegate( + name="records-child", + authority=base_authority(), + signer=child_signer, # type: ignore[arg-type] + ) + assert raised.value.code == "signer-response-mismatch" + assert provider.calls == 1 + assert parent_signer.sign_calls == 1 + assert child_signer.close_calls == 1 + + asyncio.run(scenario()) + + +def test_provider_errors_are_typed_and_sanitized() -> None: + provider = ApprovalDouble() + + async def fail(_request: ApprovalRequest) -> ApprovalResponse: + raise RuntimeError("secret provider credential") + + provider.approve = fail # type: ignore[assignment] + + async def scenario() -> None: + client, _, child_signer, _, approval = workflow_fixture( + approval_provider=provider + ) + async with client: + parent = await client.attach_agent( + name="research-agent", + profile=PROFILE, + authority=signed_root(), + approval=approval, # type: ignore[arg-type] + ) + with pytest.raises(AuthsWorkflowError) as raised: + await parent.delegate( + name="records-child", + authority=base_authority(), + signer=child_signer, # type: ignore[arg-type] + ) + assert raised.value.code == "approval-failed" + assert "credential" not in str(raised.value) + + asyncio.run(scenario()) + + +def test_cancellation_closes_the_partial_child_and_produces_no_signature() -> None: + provider = ApprovalDouble() + + async def scenario() -> None: + provider.started = asyncio.Event() + provider.release = asyncio.Event() + client, parent_signer, child_signer, _, approval = workflow_fixture( + approval_provider=provider + ) + async with client: + parent = await client.attach_agent( + name="research-agent", + profile=PROFILE, + authority=signed_root(), + approval=approval, # type: ignore[arg-type] + ) + operation = asyncio.create_task( + parent.delegate( + name="records-child", + authority=base_authority(), + signer=child_signer, # type: ignore[arg-type] + ) + ) + await provider.started.wait() + operation.cancel() + with pytest.raises(asyncio.CancelledError): + await operation + assert parent_signer.sign_calls == 0 + assert child_signer.close_calls == 1 + + asyncio.run(scenario()) + + +def test_cancellation_during_child_identity_disposes_the_child_signer() -> None: + async def scenario() -> None: + client, parent_signer, child_signer, provider, approval = workflow_fixture() + child_signer.identity_started = asyncio.Event() + child_signer.identity_release = asyncio.Event() + async with client: + parent = await client.attach_agent( + name="research-agent", + profile=PROFILE, + authority=signed_root(), + approval=approval, # type: ignore[arg-type] + ) + operation = asyncio.create_task( + parent.delegate( + name="records-child", + authority=base_authority(), + signer=child_signer, # type: ignore[arg-type] + ) + ) + await child_signer.identity_started.wait() + operation.cancel() + with pytest.raises(asyncio.CancelledError): + await operation + assert child_signer.close_calls == 1 + assert provider.calls == 0 + assert parent_signer.sign_calls == 0 + + asyncio.run(scenario()) + + +def test_cancellation_during_parent_signing_disposes_the_child_signer() -> None: + async def scenario() -> None: + client, parent_signer, child_signer, provider, approval = workflow_fixture() + parent_signer.sign_started = asyncio.Event() + parent_signer.sign_release = asyncio.Event() + async with client: + parent = await client.attach_agent( + name="research-agent", + profile=PROFILE, + authority=signed_root(), + approval=approval, # type: ignore[arg-type] + ) + operation = asyncio.create_task( + parent.delegate( + name="records-child", + authority=base_authority(), + signer=child_signer, # type: ignore[arg-type] + ) + ) + await parent_signer.sign_started.wait() + operation.cancel() + with pytest.raises(asyncio.CancelledError): + await operation + assert provider.calls == 1 + assert parent_signer.sign_calls == 1 + assert child_signer.close_calls == 1 + + asyncio.run(scenario()) + + +def test_client_cleanup_is_idempotent_for_root_and_child_signers() -> None: + async def scenario() -> None: + client, parent_signer, child_signer, _, approval = workflow_fixture() + await client.open() + parent = await client.attach_agent( + name="research-agent", + profile=PROFILE, + authority=signed_root(), + approval=approval, # type: ignore[arg-type] + ) + child = await parent.delegate( + name="records-child", + authority=base_authority(), + signer=child_signer, # type: ignore[arg-type] + ) + await client.aclose() + await client.aclose() + await child.aclose() + assert parent.closed + assert child.closed + assert parent_signer.close_calls == 1 + assert child_signer.close_calls == 1 + + asyncio.run(scenario()) + + +def test_native_transaction_is_single_use_across_approval_and_signature() -> None: + unsigned = parse_unsigned_object( + "grant", (VECTORS / "authoring.proposed-grant.cbor").read_bytes() + ) + principal = PrincipalDescriptor(PARENT, "raw-key-v1", PARENT.value, "ed25519-v1") + provider = ApprovalDouble() + approval = Approval.grant_only("approval.default", provider) + transaction = native.prepare_signing_transaction( + unsigned, + principal, + approval.policy.reference, + 200, + ) + assert transaction.accept_approval( + transaction.request_id, + transaction.transaction_digest, + transaction.policy, + "approved", + 100, + ) + with pytest.raises(RuntimeError, match="not awaiting approval"): + transaction.accept_approval( + transaction.request_id, + transaction.transaction_digest, + transaction.policy, + "approved", + 100, + ) + signed = transaction.complete_response( + transaction.request_id, + principal, + transaction.transaction_digest, + bytes([9]) * 64, + 100, + ) + assert signed.kind == "grant" + assert transaction.phase == "terminal" + with pytest.raises(RuntimeError, match="not awaiting a signature"): + transaction.complete_response( + "grant:reused", + principal, + bytes(32), + bytes([9]) * 64, + 100, + ) + + +def test_wrong_native_response_consumes_the_transaction() -> None: + unsigned = parse_unsigned_object( + "grant", (VECTORS / "authoring.proposed-grant.cbor").read_bytes() + ) + principal = PrincipalDescriptor(PARENT, "raw-key-v1", PARENT.value, "ed25519-v1") + approval = Approval.grant_only("approval.default", ApprovalDouble()) + transaction = native.prepare_signing_transaction( + unsigned, + principal, + approval.policy.reference, + 200, + ) + with pytest.raises(ValueError, match="exact transaction"): + transaction.accept_approval( + transaction.request_id, + bytes(32), + transaction.policy, + "approved", + 100, + ) + assert transaction.phase == "terminal" + with pytest.raises(RuntimeError): + transaction.accept_approval( + "reused", + bytes(32), + transaction.policy, + "approved", + 100, + ) + + +def test_expired_native_transaction_is_terminal_before_provider_completion() -> None: + unsigned = parse_unsigned_object( + "grant", (VECTORS / "authoring.proposed-grant.cbor").read_bytes() + ) + principal = PrincipalDescriptor(PARENT, "raw-key-v1", PARENT.value, "ed25519-v1") + approval = Approval.grant_only("approval.default", ApprovalDouble()) + transaction = native.prepare_signing_transaction( + unsigned, + principal, + approval.policy.reference, + 99, + ) + with pytest.raises(RuntimeError, match="expired"): + transaction.accept_approval( + transaction.request_id, + transaction.transaction_digest, + transaction.policy, + "approved", + 100, + ) + assert transaction.phase == "terminal" + with pytest.raises(RuntimeError): + transaction.accept_approval( + "reused", + bytes(32), + transaction.policy, + "approved", + 100, + ) + + +def test_profile_and_extensions_are_not_caller_selectable_during_delegation() -> None: + request = base_authority() + with pytest.raises(TypeError, match="unexpected keyword"): + DelegatedAuthority( + permissions=request.permissions, + validity=request.validity, + audiences=request.audiences, + remaining_depth=request.remaining_depth, + profile=Profile("auths.http", 1), # type: ignore[call-arg] + ) + with pytest.raises(TypeError, match="unexpected keyword"): + DelegatedAuthority( + permissions=request.permissions, + validity=request.validity, + audiences=request.audiences, + remaining_depth=request.remaining_depth, + critical_extensions=(), # type: ignore[call-arg] + ) + + +def test_provider_failure_kinds_survive_without_arbitrary_causes() -> None: + provider = ApprovalDouble() + + async def unavailable(_request: ApprovalRequest) -> ApprovalResponse: + raise ProviderOperationError("unavailable") + + provider.approve = unavailable # type: ignore[assignment] + + async def scenario() -> None: + client, _, child_signer, _, approval = workflow_fixture( + approval_provider=provider + ) + async with client: + parent = await client.attach_agent( + name="research-agent", + profile=PROFILE, + authority=signed_root(), + approval=approval, # type: ignore[arg-type] + ) + with pytest.raises(AuthsWorkflowError) as raised: + await parent.delegate( + name="records-child", + authority=base_authority(), + signer=child_signer, # type: ignore[arg-type] + ) + assert raised.value.code == "approval-failed" + + asyncio.run(scenario()) diff --git a/bindings/python/tools/check_contract.py b/bindings/python/tools/check_contract.py new file mode 100644 index 00000000..bc3eb99d --- /dev/null +++ b/bindings/python/tools/check_contract.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import importlib.util +import json +from importlib.metadata import version +from pathlib import Path + +from auths import _native + + +def main() -> None: + root = Path(__file__).parents[1] + runtime = json.loads((root / "sdk-runtime-contract.json").read_text()) + abi = json.loads((root / "native-abi-v2.json").read_text()) + capability = json.loads((root / "sdk-capability.json").read_text()) + adapters = json.loads((root / "adapter-contracts.json").read_text()) + matrix = json.loads((root.parent / "customer-journey-matrix-v1.json").read_text()) + identity = json.loads((root / "identity-conformance-v1.json").read_text()) + package = runtime["package"] + if version("auths") != package["version"]: + raise SystemExit("installed package version disagrees with runtime contract") + if _native.native_abi_version() != package["nativeAbi"]: + raise SystemExit("installed native ABI disagrees with runtime contract") + if abi["abiVersion"] != package["nativeAbi"]: + raise SystemExit("native ABI manifest disagrees with runtime contract") + for native_type in abi["types"]: + if not isinstance(getattr(_native, native_type, None), type): + raise SystemExit(f"native ABI type is unavailable: {native_type}") + for operation in (*abi["operations"], *abi["inspection"]): + if not callable(getattr(_native, operation, None)): + raise SystemExit(f"native ABI operation is unavailable: {operation}") + if capability["implementationStatus"] != "elite-repository-implementation-complete": + raise SystemExit("capability evidence does not describe the implemented SDK") + for module in runtime["excludedModules"]: + if importlib.util.find_spec(module) is not None: + raise SystemExit(f"superseded public module remains importable: {module}") + if runtime["compatibilityWindow"] is not False: + raise SystemExit("prelaunch runtime contract must not declare a compatibility window") + if runtime["distribution"] != { + "wheels": "published", + "sourceDistribution": "not-published", + "localCompilerRequired": False, + }: + raise SystemExit("Python release distribution policy drifted") + if adapters["schema"] != runtime["adapterContract"]: + raise SystemExit("adapter contracts disagree with the runtime contract") + if identity["descriptorProtocol"] != runtime["semanticSubjects"]["identity"]: + raise SystemExit("identity conformance corpus disagrees with runtime semantics") + repository = root.parents[1] + if (repository / ".git").exists(): + for journey in matrix["journeys"]: + for language in ("rust", "typescript", "python"): + if not (repository / journey[language]).exists(): + raise SystemExit( + f"customer journey evidence is missing: {journey['id']} {language}" + ) + print("Python package, native ABI, capability, and clean-break contracts agree") + + +if __name__ == "__main__": + main() diff --git a/bindings/python/tools/check_doc_snippets.py b/bindings/python/tools/check_doc_snippets.py new file mode 100644 index 00000000..fab30349 --- /dev/null +++ b/bindings/python/tools/check_doc_snippets.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import ast +from pathlib import Path + + +def snippets(document: Path) -> tuple[str, ...]: + blocks: list[str] = [] + active: list[str] | None = None + for line in document.read_text().splitlines(): + if line == "```python": + if active is not None: + raise SystemExit(f"nested Python fence in {document}") + active = [] + elif line == "```" and active is not None: + blocks.append("\n".join(active)) + active = None + elif active is not None: + active.append(line) + if active is not None: + raise SystemExit(f"unterminated Python fence in {document}") + return tuple(blocks) + + +def main() -> None: + root = Path(__file__).parents[1] + documents = (root / "README.md", root / "docs" / "INTEGRATION_RECIPES.md") + count = 0 + for document in documents: + for index, source in enumerate(snippets(document), start=1): + try: + compile( + source, + f"{document}:{index}", + "exec", + flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT, + ) + except SyntaxError as error: + raise SystemExit(str(error)) from error + count += 1 + print(f"Python documentation snippets passed: {count}") + + +if __name__ == "__main__": + main() diff --git a/bindings/python/tools/check_performance.py b/bindings/python/tools/check_performance.py new file mode 100644 index 00000000..d1f34445 --- /dev/null +++ b/bindings/python/tools/check_performance.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import asyncio +import importlib +import json +import os +import platform +import sys +import time +import tracemalloc +from pathlib import Path +from typing import Callable + + +def _p95(samples: list[float]) -> float: + return sorted(samples)[max(0, int(len(samples) * 0.95) - 1)] + + +def _timings(operation: Callable[[], object], count: int) -> list[float]: + samples: list[float] = [] + for _ in range(count): + started = time.perf_counter() + operation() + samples.append((time.perf_counter() - started) * 1000) + return samples + + +async def _event_loop_yields() -> list[float]: + samples: list[float] = [] + for _ in range(100): + started = time.perf_counter() + await asyncio.sleep(0) + samples.append((time.perf_counter() - started) * 1000) + return samples + + +def main() -> None: + if len(sys.argv) not in (3, 4) or (len(sys.argv) == 4 and sys.argv[3] != "--print"): + raise SystemExit( + "usage: check_performance.py [--print]" + ) + capture = len(sys.argv) == 4 + vectors = Path(sys.argv[1]) + wheel = Path(sys.argv[2]) + started = time.perf_counter() + verify_module = importlib.import_module("auths.verify") + mcp_module = importlib.import_module("auths.profiles.mcp") + cold_initialize_ms = (time.perf_counter() - started) * 1000 + + item = ( + (vectors / "workflow.proof.cbor").read_bytes(), + (vectors / "workflow.action.cbor").read_bytes(), + (vectors / "workflow.context.cbor").read_bytes(), + ) + verify = verify_module.verify + verify_many = verify_module.verify_many + verify(*item) + verify_many((item,) * 32) + single = _timings(lambda: verify(*item), 100) + batch = _timings(lambda: verify_many((item,) * 32), 100) + + profile = mcp_module.mcp.profile(service="performance") + actions = tuple( + profile.call("read_record", {"index": index}) for index in range(64) + ) + profile.plan(actions) + plans = _timings(lambda: profile.plan(actions), 100) + + tracemalloc.start() + verify_many((item,) * 32) + _, verify_peak_bytes = tracemalloc.get_traced_memory() + tracemalloc.stop() + + measurement = { + "coldInitializeMs": round(cold_initialize_ms, 3), + "singleVerifyMsP95": round(_p95(single), 3), + "batch32MsP95": round(_p95(batch), 3), + "plan64MsP95": round(_p95(plans), 3), + "verifyPeakBytes": verify_peak_bytes, + "eventLoopYieldMsP95": round(_p95(asyncio.run(_event_loop_yields())), 3), + "wheelBytes": wheel.stat().st_size, + } + baseline = json.loads( + (Path(__file__).parents[1] / "performance-baseline.json").read_text() + ) + expected_keys = set(baseline["measurements"]) + if set(measurement) != expected_keys: + raise SystemExit("Python performance measurement contract drifted") + + current_environment = { + "implementation": platform.python_implementation(), + "python": f"{sys.version_info.major}.{sys.version_info.minor}", + "operatingSystem": sys.platform, + "architecture": platform.machine().lower(), + "build": "release abi3 wheel", + "runner": "github-actions" + if os.environ.get("GITHUB_ACTIONS") == "true" + else "developer-reference", + } + matching_environment = current_environment == baseline["environment"] + if not capture: + for key, limit in baseline["hardLimits"].items(): + if measurement[key] > limit: + raise SystemExit(f"{key} exceeded the cross-platform hard limit") + if matching_environment and not capture: + runtime_threshold = 1 + baseline["reviewThresholds"]["runtimeRegressionPercent"] / 100 + wheel_threshold = 1 + baseline["reviewThresholds"]["wheelSizeRegressionPercent"] / 100 + for key in ( + "coldInitializeMs", + "singleVerifyMsP95", + "batch32MsP95", + "plan64MsP95", + "eventLoopYieldMsP95", + ): + budget = baseline["measurements"][key] * runtime_threshold + if measurement[key] > budget: + raise SystemExit( + f"{key} exceeded the matching-runner regression budget: " + f"observed {measurement[key]}, budget {budget:.3f}" + ) + wheel_budget = baseline["measurements"]["wheelBytes"] * wheel_threshold + if measurement["wheelBytes"] > wheel_budget: + raise SystemExit( + "wheelBytes exceeded the matching-runner regression budget: " + f"observed {measurement['wheelBytes']}, budget {wheel_budget:.0f}" + ) + + print(json.dumps({"environment": current_environment, "matchingEnvironment": matching_environment, "measurement": measurement}, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/bindings/python/tools/check_public_api.py b/bindings/python/tools/check_public_api.py new file mode 100644 index 00000000..984c0cb0 --- /dev/null +++ b/bindings/python/tools/check_public_api.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +import auths +import auths.approvals +import auths.authority +import auths.custody +import auths.diagnostics +import auths.errors +import auths.identity +import auths.inspection +import auths.integrations +import auths.lifecycle +import auths.observability +import auths.profile_kit +import auths.profiles.http +import auths.profiles.mcp +import auths.runtime +import auths.testkit +import auths.trust +import auths.verify + + +def projection() -> str: + sections = { + "auths": auths.__all__, + "auths.approvals": auths.approvals.__all__, + "auths.authority": auths.authority.__all__, + "auths.custody": auths.custody.__all__, + "auths.diagnostics": auths.diagnostics.__all__, + "auths.errors": auths.errors.__all__, + "auths.identity": auths.identity.__all__, + "auths.inspection": auths.inspection.__all__, + "auths.integrations": auths.integrations.__all__, + "auths.lifecycle": auths.lifecycle.__all__, + "auths.observability": auths.observability.__all__, + "auths.profile_kit": auths.profile_kit.__all__, + "auths.profiles.http": auths.profiles.http.__all__, + "auths.profiles.mcp": auths.profiles.mcp.__all__, + "auths.runtime": auths.runtime.__all__, + "auths.testkit": auths.testkit.__all__, + "auths.trust": auths.trust.__all__, + "auths.verify": auths.verify.__all__, + } + lines = [] + for module, names in sections.items(): + lines.append("[" + module + "]") + lines.extend(sorted(names)) + lines.append("") + return "\n".join(lines) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--update", action="store_true") + args = parser.parse_args() + snapshot = Path(__file__).parents[1] / "api" / "public-api.txt" + actual = projection() + if args.update: + snapshot.parent.mkdir(parents=True, exist_ok=True) + snapshot.write_text(actual) + return + if snapshot.read_text() != actual: + raise SystemExit( + "installed Python public API drifted; review exports and update api/public-api.txt" + ) + print("Python public API snapshot passed") + + +if __name__ == "__main__": + main() diff --git a/bindings/python/tools/check_wheel.py b/bindings/python/tools/check_wheel.py new file mode 100644 index 00000000..c1f81e90 --- /dev/null +++ b/bindings/python/tools/check_wheel.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import sys +import zipfile +from pathlib import Path, PurePosixPath + + +REQUIRED_PACKAGE_FILES = { + "auths/__init__.py", + "auths/_native.pyi", + "auths/_plan.py", + "auths/approvals.py", + "auths/authority.py", + "auths/custody.py", + "auths/diagnostics.py", + "auths/errors.py", + "auths/identity.py", + "auths/inspection.py", + "auths/integrations.py", + "auths/lifecycle.py", + "auths/observability.py", + "auths/profile_kit.py", + "auths/profiles/__init__.py", + "auths/profiles/http.py", + "auths/profiles/mcp.py", + "auths/py.typed", + "auths/runtime.py", + "auths/testkit.py", + "auths/trust.py", + "auths/verify.py", + "auths/workflow.py", +} +FORBIDDEN_PARTS = { + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + "fixtures", + "target", + "tests", + "typecheck", +} +FORBIDDEN_SUFFIXES = {".pyc", ".pyo", ".rs", ".seed", ".key", ".pem"} + + +def main() -> None: + if len(sys.argv) != 2: + raise SystemExit("usage: check_wheel.py ") + wheel = Path(sys.argv[1]).resolve() + if wheel.suffix != ".whl" or not wheel.is_file(): + raise SystemExit("expected one built wheel") + with zipfile.ZipFile(wheel) as archive: + files = {name for name in archive.namelist() if not name.endswith("/")} + for name in files: + path = PurePosixPath(name) + if path.is_absolute() or ".." in path.parts: + raise SystemExit("wheel contains an unsafe path") + lowered = {part.lower() for part in path.parts} + if lowered & FORBIDDEN_PARTS or path.suffix.lower() in FORBIDDEN_SUFFIXES: + raise SystemExit(f"wheel contains a forbidden entry: {name}") + if any( + marker in name.lower() + for marker in ("private-key", "secret", "seed.bin") + ): + raise SystemExit( + f"wheel contains sensitive development material: {name}" + ) + missing = REQUIRED_PACKAGE_FILES - files + if missing: + raise SystemExit( + "wheel omitted required package files: " + ", ".join(sorted(missing)) + ) + extensions = { + name + for name in files + if name.startswith("auths/_native.") + and PurePosixPath(name).suffix.lower() in {".so", ".pyd"} + } + if len(extensions) != 1: + raise SystemExit("wheel must contain exactly one native extension") + unexpected = { + name + for name in files + if not name.startswith("auths/") and ".dist-info/" not in name + } + if unexpected: + raise SystemExit( + "wheel contains unexpected package roots: " + + ", ".join(sorted(unexpected)) + ) + metadata_names = [ + name for name in files if name.endswith(".dist-info/METADATA") + ] + if len(metadata_names) != 1: + raise SystemExit("wheel must contain one METADATA record") + metadata = archive.read(metadata_names[0]).decode("utf-8") + required = ( + "Name: auths\n", + "Version: 1.0.0rc1\n", + "Requires-Python: >=3.9\n", + "Classifier: Operating System :: Microsoft :: Windows\n", + "Classifier: Operating System :: MacOS\n", + "Classifier: Operating System :: POSIX :: Linux\n", + "Classifier: Programming Language :: Python :: 3.9\n", + "Classifier: Programming Language :: Python :: 3.14\n", + "Classifier: Typing :: Typed\n", + ) + if any(field not in metadata for field in required): + raise SystemExit("wheel metadata does not match the public Python contract") + print(f"Python wheel contents passed: {len(files)} files") + + +if __name__ == "__main__": + main() diff --git a/bindings/python/typecheck/elite_consumer.py b/bindings/python/typecheck/elite_consumer.py new file mode 100644 index 00000000..36a09a88 --- /dev/null +++ b/bindings/python/typecheck/elite_consumer.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from auths.authority import ProofPlan, ProofPlanBuilder, ProofReference +from auths.identity import ( + AuthenticatedIdentity, + IdentityRegistry, + decode_identity, +) +from auths.runtime import BudgetReservation, InMemoryRuntimeStore +from auths.verify import VerificationInput, VerificationResult, verify_many + + +async def authenticate( + packet: bytes, + message: bytes, + signature: bytes, + registry: IdentityRegistry, +) -> AuthenticatedIdentity: + decoded = decode_identity(packet) + resolved = await decoded.resolve(registry) + validated = await resolved.validate(registry) + return await validated.authenticate(message, signature, registry) + + +def compose_plan(first: bytes, second: bytes) -> ProofPlan: + builder = ProofPlanBuilder() + return builder.threshold( + 1, + ( + builder.proof(ProofReference(first)), + builder.proof(ProofReference(second)), + ), + ) + + +def batch(values: tuple[VerificationInput, ...]) -> tuple[VerificationResult, ...]: + return verify_many(values) + + +async def reserve(store: InMemoryRuntimeStore, commitment: bytes) -> BudgetReservation: + return await store.reserve(commitment, "numeric-ceiling-v1", 1) diff --git a/bindings/python/typecheck/installed-pyrightconfig.json b/bindings/python/typecheck/installed-pyrightconfig.json new file mode 100644 index 00000000..a7b23eda --- /dev/null +++ b/bindings/python/typecheck/installed-pyrightconfig.json @@ -0,0 +1,11 @@ +{ + "include": [ + "mcp_consumer.py", + "elite_consumer.py", + "workflow_consumer.py", + "pyright_negative.py" + ], + "pythonVersion": "3.9", + "typeCheckingMode": "strict", + "reportUnnecessaryTypeIgnoreComment": "error" +} diff --git a/bindings/python/typecheck/mcp_consumer.py b/bindings/python/typecheck/mcp_consumer.py new file mode 100644 index 00000000..aadcb25a --- /dev/null +++ b/bindings/python/typecheck/mcp_consumer.py @@ -0,0 +1,55 @@ +from typing import Awaitable, Callable + +from auths import AttachedAgent +from auths.profiles.mcp import ( + AuthorizationRequest, + McpAuthorizationResult, + McpGatewayCall, + McpPlanAuthorizationResult, + McpProfile, +) + + +async def authorize_and_execute( + *, + agent: AttachedAgent, + profile: McpProfile, + execute: Callable[[McpGatewayCall], Awaitable[str]], +) -> McpAuthorizationResult: + action = profile.call("update_demo_record", {"value": "reviewed"}) + result = await agent.authorize(action, request=AuthorizationRequest()) + if result.kind == "authorized": + await profile.gateway(execute).execute( + result.command, idempotency_key="typecheck-action" + ) + elif result.kind == "denied": + assert not result.explanation.retryable + else: + assert result.explanation.retryable + return result + + +async def authorize_plan_and_execute( + *, + agent: AttachedAgent, + profile: McpProfile, + execute: Callable[[McpGatewayCall], Awaitable[str]], +) -> McpPlanAuthorizationResult: + plan = profile.plan( + ( + profile.call("prepare_report", {"month": "august"}), + profile.call("publish_report", {"month": "august"}), + ) + ) + result = await agent.authorize_plan( + plan, + requests=(AuthorizationRequest(), AuthorizationRequest()), + ) + if result.kind == "authorized": + await profile.gateway(execute).execute_plan( + result.command, idempotency_key="typecheck-plan" + ) + else: + assert result.failed_index >= 0 + assert result.result.kind in ("denied", "indeterminate") + return result diff --git a/bindings/python/typecheck/mypy_negative.py b/bindings/python/typecheck/mypy_negative.py new file mode 100644 index 00000000..0d461f46 --- /dev/null +++ b/bindings/python/typecheck/mypy_negative.py @@ -0,0 +1,10 @@ +from auths.profiles.mcp import McpGatewayCall, McpProfile + + +async def capability_boundaries(profile: McpProfile, raw: bytes) -> None: + async def execute(_call: McpGatewayCall) -> None: + return None + + gateway = profile.gateway(execute) + await gateway.execute(raw, idempotency_key="negative") # type: ignore[arg-type] + await gateway.execute_plan(raw, idempotency_key="negative") # type: ignore[arg-type] diff --git a/bindings/python/typecheck/pyright_negative.py b/bindings/python/typecheck/pyright_negative.py new file mode 100644 index 00000000..96bd0205 --- /dev/null +++ b/bindings/python/typecheck/pyright_negative.py @@ -0,0 +1,10 @@ +from auths.profiles.mcp import McpGatewayCall, McpProfile + + +async def capability_boundaries(profile: McpProfile, raw: bytes) -> None: + async def execute(_call: McpGatewayCall) -> None: + return None + + gateway = profile.gateway(execute) + await gateway.execute(raw, idempotency_key="negative") # pyright: ignore[reportArgumentType] + await gateway.execute_plan(raw, idempotency_key="negative") # pyright: ignore[reportArgumentType] diff --git a/bindings/python/typecheck/workflow_consumer.py b/bindings/python/typecheck/workflow_consumer.py new file mode 100644 index 00000000..70aad989 --- /dev/null +++ b/bindings/python/typecheck/workflow_consumer.py @@ -0,0 +1,49 @@ +from auths import ( + Approval, + ApprovalProvider, + AttachedAgent, + AuthsClient, + BudgetCeiling, + DelegatedAuthority, + Permission, + Profile, + SignedGrantInput, + Signer, + SnapshotRequired, + TrustedAuthority, + Validity, +) + + +async def attach_and_delegate( + *, + parent_signer: Signer, + child_signer: Signer, + approval_provider: ApprovalProvider, + trusted_authority: TrustedAuthority, + root_grant: SignedGrantInput, +) -> AttachedAgent: + approval = Approval.grant_only("approval.default", approval_provider) + client = AuthsClient( + signer=parent_signer, + trusted_authority=trusted_authority, + ) + await client.open() + parent = await client.attach_agent( + name="research-agent", + profile=Profile("auths.mcp", 1), + authority=root_grant, + approval=approval, + ) + return await parent.delegate( + name="records-child", + authority=DelegatedAuthority( + permissions=(Permission("tools/call", "mcp://records/tools/update"),), + validity=Validity(20, 80), + audiences=("mcp://records",), + remaining_depth=0, + budget=BudgetCeiling("numeric-ceiling-v1", 1), + status=SnapshotRequired("status.local-v1", 30), + ), + signer=child_signer, + ) diff --git a/bindings/typescript/test/integration/profiles/mcp.test.js b/bindings/typescript/test/integration/profiles/mcp.test.js index 63f0a8d0..7ff0d12b 100644 --- a/bindings/typescript/test/integration/profiles/mcp.test.js +++ b/bindings/typescript/test/integration/profiles/mcp.test.js @@ -1,4 +1,5 @@ import { createPrivateKey, sign as signBytes } from "node:crypto"; +import { readFileSync } from "node:fs"; import { test } from "node:test"; import assert from "node:assert/strict"; import { @@ -10,6 +11,7 @@ import { trustedContextSource, } from "../../../dist/index.js"; import { inspectDecision } from "../../../dist/inspection.js"; +import { loadVerifier } from "../../../dist/verify.js"; import { McpAction, McpCommand, mcp } from "../../../dist/mcp.js"; import { ApplicationAction, ApplicationCommand, defineProfile } from "../../../dist/profile-kit.js"; import { @@ -23,6 +25,12 @@ import { vector, } from "../helpers/mcp-fixture.js"; +const workflowProjection = () => JSON.parse(readFileSync( + new URL("../../../../../target/binding-vectors/workflow.projection.json", import.meta.url), + "utf8", +)); +const hex = (value) => Buffer.from(value).toString("hex"); + test("application profile kit uses the native authoring and verification path", async () => { const originalNow = Date.now; Date.now = () => 50_000; @@ -191,6 +199,64 @@ test("MCP facade canonicalizes signs assembles and authorizes locally", async () } }); +test("shared Rust workflow projection matches TypeScript", async () => { + const projection = workflowProjection(); + const verifier = await loadVerifier(); + const result = verifier.verify( + vector("workflow.proof.cbor"), + vector("workflow.action.cbor"), + vector("workflow.context.cbor"), + ); + const inspection = await inspectDecision(result); + + assert.equal(projection.schema, "auths.full-workflow-projection/1"); + assert.equal(result.kind, projection.verdict); + assert.equal(result.stage, projection.stage); + assert.equal(result.code, projection.code); + assert.deepEqual( + Object.fromEntries(Object.entries(result.metrics).map(([key, value]) => [key, Number(value)])), + projection.metrics, + ); + assert.equal(hex(result.resultCbor), hex(vector("workflow.result.cbor"))); + assert.equal(hex(inspection.commitments.action), projection.commitments.action); + assert.equal(hex(inspection.commitments.result), projection.commitments.result); + assert.equal( + hex(inspection.commitments.localConfiguration), + projection.commitments.localConfiguration, + ); + + const profile = mcp.profile({ service: projection.command.service }); + const action = profile.call( + projection.command.name, + JSON.parse(projection.command.argumentsJson), + ); + const plan = await profile.plan([action, action]); + assert.equal(hex(plan.commitment), projection.commitments.plan); + const wasm = await packagedWasm(); + assert.equal( + hex(wasm.commitPlanApprovalV1(plan.commitment, new Uint8Array(32).fill(7), 2, 350n)), + projection.commitments.planApproval, + ); + + const originalNow = Date.now; + Date.now = () => 50_000; + try { + const { client, agent, profile: attachedProfile } = await fixture(); + const authorized = await agent.authorize( + attachedProfile.call(projection.command.name, JSON.parse(projection.command.argumentsJson)), + ); + assert.equal(authorized.kind, "authorized"); + const calls = []; + await attachedProfile.gateway(async (call) => calls.push(call)).execute(authorized.command); + assert.equal(calls[0].service, projection.command.service); + assert.equal(calls[0].name, projection.command.name); + assert.equal(new TextDecoder().decode(calls[0].argumentsJson), projection.command.argumentsJson); + await client.dispose(); + } finally { + Date.now = originalNow; + } +}); + test("MCP canonical JSON is independent of JavaScript object insertion order", async () => { const originalNow = Date.now; Date.now = () => 50_000; diff --git a/bindings/wasm/auths-proof-wasm/authoring-abi-v1.json b/bindings/wasm/auths-proof-wasm/authoring-abi-v1.json index 4e007ed3..b54368fb 100644 --- a/bindings/wasm/auths-proof-wasm/authoring-abi-v1.json +++ b/bindings/wasm/auths-proof-wasm/authoring-abi-v1.json @@ -91,7 +91,15 @@ "mcp.root-evidence.bin", "mcp.actor-evidence.bin", "mcp.root-seed.bin", - "mcp.actor-seed.bin" + "mcp.actor-seed.bin", + "mcp.action-signature.bin", + "mcp.denied-action-signature.bin", + "mcp.signed-child-grant.cbor", + "mcp.child-grant-signature.bin", + "mcp.child-action-signature.bin", + "mcp.child-evidence.bin", + "mcp.child-seed.bin", + "mcp.child-principal.txt" ] } } diff --git a/bindings/wasm/auths-proof-wasm/examples/generate-node-vectors.rs b/bindings/wasm/auths-proof-wasm/examples/generate-node-vectors.rs index 0f817b40..abd45287 100644 --- a/bindings/wasm/auths-proof-wasm/examples/generate-node-vectors.rs +++ b/bindings/wasm/auths-proof-wasm/examples/generate-node-vectors.rs @@ -164,6 +164,7 @@ fn write_scenario_vectors(output: &std::path::Path) -> Result<(), Box Result<(), Box> { let root_key = SigningKey::from_bytes(&[11; 32]); let actor_key = SigningKey::from_bytes(&[12; 32]); + let child_key = SigningKey::from_bytes(&[13; 32]); let root_descriptor = auths_raw_key::RawKeyDescriptor::new( auths_raw_key::RawKeyType::Ed25519, root_key.verifying_key().to_bytes().to_vec(), @@ -174,8 +175,14 @@ fn write_mcp_workflow_vectors(output: &std::path::Path) -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result<(), Box> { + let prepared = auths_author::prepare_profile_action( + canonical.clone(), + call.audience()?, + actor.clone(), + root_grant, + [0x22; 32], + 50, + )?; + let signing = auths_author::prepare_action( + prepared.envelope().clone(), + auths_model::SignatureDescriptor::new( + auths_model::PrincipalMethodId::parse(auths_raw_key::RAW_KEY_V1)?, + auths_model::VerificationMethod::parse(actor.as_str())?, + auths_model::SignatureSuiteId::parse("ed25519-v1")?, + ), + )?; + let signature = actor_key.sign(signing.signing_preimage()); + let signed_action = signing.complete(auths_model::SignatureBytes::new( + signature.to_bytes().to_vec(), + )?); + let mut proof = auths_author::WorkflowProofBuilder::new(); + let grant_index = proof.push_grant(root_grant.clone())?; + proof.bind_grant_evidence( + grant_index, + auths_author::address_evidence( + auths_model::EvidenceTypeId::parse(auths_raw_key::RAW_KEY_V1)?, + auths_model::MediaType::parse("application/vnd.auths.raw-key.v1")?, + root_descriptor.encode(), + )?, + )?; + proof.bind_action_evidence(auths_author::address_evidence( + auths_model::EvidenceTypeId::parse(auths_raw_key::RAW_KEY_V1)?, + auths_model::MediaType::parse("application/vnd.auths.raw-key.v1")?, + actor_descriptor.encode(), + )?)?; + let artifacts = proof.finish(&signed_action, canonical, context)?; + let proof_cbor = auths_codec::encode_bundle(artifacts.proof())?; + let action_cbor = auths_codec::encode_canonical_action(canonical)?; + let context_cbor = auths_codec::encode_verifier_context(artifacts.context())?; + let result_cbor = + auths_proof_wasm::verify_self_contained_v1(&proof_cbor, &action_cbor, &context_cbor)?; + fs::write(output.join("workflow.proof.cbor"), &proof_cbor)?; + fs::write(output.join("workflow.action.cbor"), &action_cbor)?; + fs::write(output.join("workflow.context.cbor"), &context_cbor)?; + fs::write(output.join("workflow.result.cbor"), &result_cbor)?; + let result = auths_codec::decode_verification_result(&result_cbor)?; + let member = auths_author::ProfilePlanMember::encode( + canonical, + &auths_model::ResourceId::parse("mcp://reports")?, + &call.audience()?, + )?; + let plan = auths_author::ProfilePlanCommitment::commit( + auths_profile_mcp::PROFILE_ID, + auths_profile_mcp::PROFILE_VERSION, + &[member.as_slice(), member.as_slice()], + )?; + let plan_approval = + auths_author::commit_plan_approval(plan.plan().as_bytes(), &[7; 32], 2, 350)?; + let resources = result.resources(); + let projection = serde_json::json!({ + "schema": "auths.full-workflow-projection/1", + "verdict": decision_label(result.decision()), + "stage": stage_label(result.stage()), + "code": result.code().code(), + "commitments": { + "action": hex(auths_codec::domain_commitment("auths.canonical-action.v1", &action_cbor)?.as_bytes()), + "result": hex(auths_codec::domain_commitment("auths.verification-result.v1", &result_cbor)?.as_bytes()), + "localConfiguration": hex(auths_codec::domain_commitment( + "auths.verifier-configuration.v1", + result.local_configuration().as_bytes(), + )?.as_bytes()), + "plan": hex(plan.plan().as_bytes()), + "planMembers": plan.members().iter().map(|value| hex(value.as_bytes())).collect::>(), + "planApproval": hex(plan_approval.as_bytes()), + }, + "metrics": { + "proofBytes": resources.proof_bytes(), + "actionBytes": resources.action_bytes(), + "contextBytes": resources.context_bytes(), + "objectCount": resources.object_count(), + "planLeaves": resources.plan_leaves(), + "planDepth": resources.plan_depth(), + "workUnits": resources.work_units(), + }, + "authorityDiff": { + "removedPermissions": child_diff.removed_permissions(), + "removedAudiences": child_diff.removed_audiences(), + "validityShortened": child_diff.validity_shortened(), + "actionNarrowed": child_diff.action_narrowed(), + "budgetNarrowed": child_diff.budget_narrowed(), + "statusNarrowed": child_diff.status_narrowed(), + "delegationDepth": child_diff.delegation_depth(), + }, + "command": { + "profile": "auths.mcp/1", + "service": call.service(), + "name": call.name(), + "argumentsJson": String::from_utf8(serde_json_canonicalizer::to_vec(call.arguments())?)?, + }, + }); + fs::write( + output.join("workflow.projection.json"), + serde_json::to_vec_pretty(&projection)?, + )?; Ok(()) } +fn decision_label(decision: auths_model::VerificationDecision) -> &'static str { + match decision { + auths_model::VerificationDecision::Authorized => "authorized", + auths_model::VerificationDecision::Denied => "denied", + auths_model::VerificationDecision::Indeterminate => "indeterminate", + } +} + +fn stage_label(stage: auths_model::VerificationStage) -> &'static str { + match stage { + auths_model::VerificationStage::Decode => "decode", + auths_model::VerificationStage::Resolve => "resolve", + auths_model::VerificationStage::PrincipalControl => "principal-control", + auths_model::VerificationStage::Authority => "authority", + auths_model::VerificationStage::Complete => "complete", + } +} + +fn hex(bytes: &[u8]) -> String { + const DIGITS: &[u8; 16] = b"0123456789abcdef"; + let mut value = String::with_capacity(bytes.len() * 2); + for byte in bytes { + value.push(DIGITS[usize::from(byte >> 4)] as char); + value.push(DIGITS[usize::from(byte & 0x0f)] as char); + } + value +} + fn delegation_root( proposed: &auths_model::GrantStatement, signed: &auths_model::SignedGrant, diff --git a/bindings/wasm/auths-proof-wasm/src/lib.rs b/bindings/wasm/auths-proof-wasm/src/lib.rs index 11dac262..0d879eb8 100644 --- a/bindings/wasm/auths-proof-wasm/src/lib.rs +++ b/bindings/wasm/auths-proof-wasm/src/lib.rs @@ -4,8 +4,9 @@ use auths_author::{ ApprovalPolicyCommitment, ExternalSigningRequest, GrantPlan, GrantRequest, OverGrantingWarning, - ProfilePlanCommitment, ProfilePlanMember, commit_plan_approval, plan_child_grant, - prepare_action, prepare_grant, prepare_grant_status, prepare_principal_status, + ProfilePlanCommitment, ProfilePlanMember, WorkflowAssemblyError, WorkflowProofBuilder, + address_evidence, commit_plan_approval, plan_child_grant, prepare_action, prepare_grant, + prepare_grant_status, prepare_principal_status, prepare_profile_action, }; use auths_identity::{ IdentityDescriptor, IdentityPacket, PublicIdentity, SignedIdentityMessage, @@ -15,15 +16,14 @@ use auths_identity_raw_key::RawKeyIdentityMethod; use auths_model::{ AcceptedRegistries, ActionConstraint, ActionEnvelope, AssuranceClaimId, AssuranceImplicationId, AssurancePolicy, AssurancePolicyId, AssuranceQuantifier, AssuranceRequirement, Audience, - AudienceSet, AuthorizationPlan, BodyDigestSet, BudgetAlgebraId, BudgetCeiling, BundleHeader, - CapabilityId, Challenge, ChannelBindingId, CompositionRequirement, ControlBinding, - CriticalExtension, CriticalExtensions, Digest, EvidenceId, EvidenceObject, EvidenceTypeId, - ExtensionId, FreshnessLimit, GrantId, GrantState, GrantStatusSnapshot, GrantStatusStatement, - LimitKind, MediaType, ParticipantRole, Permission, PermissionSet, PrincipalId, - PrincipalMethodId, PrincipalState, PrincipalStatusSnapshot, PrincipalStatusStatement, - ProfileId, ProfilePolicyId, ProfileRef, ProofBundle, ProofRef, PurposeId, ResourceId, - ResourceMatcherId, SignatureBytes, SignatureDescriptor, SignatureSuiteId, SignedGrant, - StatementRef, StatusMethodId, StatusPolicy, StatusSnapshotId, StatusTrustRule, Timestamp, + AudienceSet, AuthorizationPlan, BodyDigestSet, BudgetAlgebraId, BudgetCeiling, CapabilityId, + Challenge, ChannelBindingId, CompositionRequirement, CriticalExtension, CriticalExtensions, + Digest, EvidenceId, EvidenceObject, EvidenceTypeId, ExtensionId, FreshnessLimit, GrantId, + GrantState, GrantStatusSnapshot, GrantStatusStatement, LimitKind, MediaType, ParticipantRole, + Permission, PermissionSet, PrincipalId, PrincipalMethodId, PrincipalState, + PrincipalStatusSnapshot, PrincipalStatusStatement, ProfileId, ProfilePolicyId, ProfileRef, + ProofRef, PurposeId, ResourceId, ResourceMatcherId, SignatureBytes, SignatureDescriptor, + SignatureSuiteId, StatusMethodId, StatusPolicy, StatusSnapshotId, StatusTrustRule, Timestamp, TrustAnchor, TrustAnchorId, ValidityWindow, VerificationMethod, VerifierConfigurationId, VerifierContext, VerifierLimits, }; @@ -3256,35 +3256,22 @@ fn prepare_mcp_action_native( let challenge: [u8; 32] = challenge .try_into() .map_err(|_| EngineError::Abi("challenge must contain exactly 32 bytes"))?; - let proof_ref = ProofRef::new(challenge); - let plan = AuthorizationPlan::proof(proof_ref); - let envelope = ActionEnvelope::new( - canonical.profile().clone(), - canonical.media_type().clone(), - auths_codec::body_digest(canonical.body()), - canonical.permission().clone(), - canonical.requested_budget().cloned(), + let resource = canonical.permission().resource().to_string(); + let prepared = prepare_profile_action( + canonical, call.audience()?, - Challenge::new(challenge), - ValidityWindow::new( - Timestamp::new(evaluation_time), - Timestamp::new(evaluation_time), - )?, PrincipalId::parse(actor)?, - Some(auths_codec::grant_id(terminal_grant.statement())?), - auths_codec::plan_id(&plan)?, - ChannelBindingId::parse("none-v1")?, - proof_ref, - Vec::new(), - CriticalExtensions::empty(), - ); + &terminal_grant, + challenge, + evaluation_time, + )?; Ok(McpActionPreparationV1 { - canonical_action_cbor: auths_codec::encode_canonical_action(&canonical)?, - action_envelope_cbor: auths_codec::encode_action_envelope(&envelope)?, + canonical_action_cbor: auths_codec::encode_canonical_action(prepared.canonical())?, + action_envelope_cbor: auths_codec::encode_action_envelope(prepared.envelope())?, arguments_json: serde_json_canonicalizer::to_vec(call.arguments()) .map_err(|_| EngineError::Abi("MCP arguments could not be canonicalized"))?, audience: call.audience()?.to_string(), - resource: canonical.permission().resource().to_string(), + resource, display_digest_hex: display.canonical_digest_hex().to_owned(), }) } @@ -3382,17 +3369,10 @@ fn canonical_profile_action_native( .map_err(EngineError::from) } -#[derive(Clone)] -struct GrantProofMaterial { - grant: SignedGrant, - evidence: Vec, -} - /// Native, bounded proof-material collector used only by the workflow facade. #[wasm_bindgen] pub struct WorkflowProofBuilderV1 { - grants: Vec, - action_evidence: Vec, + inner: WorkflowProofBuilder, } #[wasm_bindgen] @@ -3402,8 +3382,7 @@ impl WorkflowProofBuilderV1 { #[must_use] pub fn new() -> Self { Self { - grants: Vec::new(), - action_evidence: Vec::new(), + inner: WorkflowProofBuilder::new(), } } @@ -3414,24 +3393,13 @@ impl WorkflowProofBuilderV1 { /// Returns a JavaScript error for malformed grants or collection overflow. #[wasm_bindgen(js_name = pushGrant)] pub fn push_grant(&mut self, signed_grant_cbor: &[u8]) -> Result { - if self.grants.len() - >= VerifierLimits::default_deployment().get(auths_model::LimitKind::Grants) - { - return Err(js_error(EngineError::Abi( - "grant chain exceeds deployment limit", - ))); - } let grant = auths_codec::decode_signed_grant( signed_grant_cbor, &VerifierLimits::default_deployment(), ) .map_err(js_error)?; - self.grants.push(GrantProofMaterial { - grant, - evidence: Vec::new(), - }); - u32::try_from(self.grants.len() - 1) - .map_err(|_| js_error(EngineError::Abi("grant index exceeds ABI"))) + let index = self.inner.push_grant(grant).map_err(js_error)?; + u32::try_from(index).map_err(|_| js_error(EngineError::Abi("grant index exceeds ABI"))) } /// Binds one typed public evidence object to a previously added grant. @@ -3448,14 +3416,12 @@ impl WorkflowProofBuilderV1 { media_type: &str, bytes: &[u8], ) -> Result<(), JsValue> { - let material = self - .grants - .get_mut(usize::try_from(grant_index).map_err(js_error)?) - .ok_or_else(|| js_error(EngineError::Abi("grant evidence index is invalid")))?; - material - .evidence - .push(addressed_evidence(evidence_type, media_type, bytes).map_err(js_error)?); - Ok(()) + self.inner + .bind_grant_evidence( + usize::try_from(grant_index).map_err(js_error)?, + addressed_evidence(evidence_type, media_type, bytes).map_err(js_error)?, + ) + .map_err(js_error) } /// Binds one typed public evidence object to the signed action. @@ -3471,9 +3437,11 @@ impl WorkflowProofBuilderV1 { media_type: &str, bytes: &[u8], ) -> Result<(), JsValue> { - self.action_evidence - .push(addressed_evidence(evidence_type, media_type, bytes).map_err(js_error)?); - Ok(()) + self.inner + .bind_action_evidence( + addressed_evidence(evidence_type, media_type, bytes).map_err(js_error)?, + ) + .map_err(js_error) } /// Assembles the canonical proof and exact request-bound trusted context. @@ -3513,55 +3481,11 @@ impl WorkflowProofBuilderV1 { let limits = VerifierLimits::default_deployment(); let action = auths_codec::decode_signed_action(signed_action_cbor, &limits)?; let canonical = auths_codec::decode_canonical_action(canonical_action_cbor, &limits)?; - let plan = AuthorizationPlan::proof(action.envelope().proof_ref()); - if auths_codec::plan_id(&plan)? != action.envelope().authorization_plan() { - return Err(EngineError::Abi( - "signed action does not bind its authorization plan", - )); - } - let mut evidence = Vec::new(); - let mut bindings = Vec::new(); - for material in &self.grants { - let ids = unique_evidence(&mut evidence, &material.evidence); - if !ids.is_empty() { - bindings.push(ControlBinding::new( - StatementRef::Grant(auths_codec::grant_id(material.grant.statement())?), - ids, - )?); - } - } - let action_ids = unique_evidence(&mut evidence, &self.action_evidence); - if !action_ids.is_empty() { - bindings.push(ControlBinding::new( - StatementRef::Action(auths_codec::action_id(action.envelope())?), - action_ids, - )?); - } - let proof = ProofBundle::new( - BundleHeader::v1(), - self.grants - .iter() - .map(|material| material.grant.clone()) - .collect(), - vec![action.clone()], - plan.clone(), - evidence, - bindings, - Vec::new(), - Vec::new(), - Vec::new(), - Some(canonical.body().to_vec()), - )?; - let context = auths_codec::decode_verifier_context(trusted_context_cbor)? - .for_request( - action.envelope().audience().clone(), - action.envelope().challenge(), - action.envelope().validity().not_before(), - )? - .with_composition(CompositionRequirement::exact(auths_codec::plan_id(&plan)?))?; + let context = auths_codec::decode_verifier_context(trusted_context_cbor)?; + let artifacts = self.inner.finish(&action, &canonical, &context)?; Ok(WorkflowAuthorizationArtifactsV1 { - proof_cbor: auths_codec::encode_bundle(&proof)?, - trusted_context_cbor: auths_codec::encode_verifier_context(&context)?, + proof_cbor: auths_codec::encode_bundle(artifacts.proof())?, + trusted_context_cbor: auths_codec::encode_verifier_context(artifacts.context())?, }) } } @@ -3633,35 +3557,13 @@ fn addressed_evidence( media_type: &str, bytes: &[u8], ) -> Result { - let evidence_type = EvidenceTypeId::parse(evidence_type)?; - let media_type = MediaType::parse(media_type)?; - let unaddressed = EvidenceObject::new( - EvidenceId::new([0; 32]), - evidence_type.clone(), - media_type.clone(), - bytes.to_vec(), - )?; - Ok(EvidenceObject::new( - auths_codec::evidence_id(&unaddressed)?, - evidence_type, - media_type, + Ok(address_evidence( + EvidenceTypeId::parse(evidence_type)?, + MediaType::parse(media_type)?, bytes.to_vec(), )?) } -fn unique_evidence(all: &mut Vec, additions: &[EvidenceObject]) -> Vec { - let mut ids = Vec::with_capacity(additions.len()); - for object in additions { - if !all.iter().any(|candidate| candidate.id() == object.id()) { - all.push(object.clone()); - } - if !ids.contains(&object.id()) { - ids.push(object.id()); - } - } - ids -} - fn plan_child_grant_native( parent_grant_cbor: &[u8], proposed_child_cbor: &[u8], @@ -4042,6 +3944,8 @@ pub enum EngineError { Planning(auths_author::PlanningError), /// Exact signing-input construction failed. Author(auths_author::AuthorError), + /// Exact action or authorization-artifact assembly failed. + Workflow(WorkflowAssemblyError), /// MCP profile construction or canonicalization failed. Mcp(auths_profile_mcp::ProfileError), /// Profile contract construction or projection failed. @@ -4071,6 +3975,7 @@ impl fmt::Display for EngineError { } Self::Planning(error) => write!(formatter, "could not plan child authority: {error}"), Self::Author(error) => write!(formatter, "could not prepare signing request: {error}"), + Self::Workflow(error) => write!(formatter, "could not assemble workflow: {error}"), Self::Mcp(error) => write!(formatter, "could not construct MCP action: {error}"), Self::Profile(error) => write!(formatter, "MCP profile contract failed: {error}"), Self::Identity(error) => write!(formatter, "identity descriptor failed: {error}"), @@ -4111,6 +4016,12 @@ impl From for EngineError { } } +impl From for EngineError { + fn from(error: WorkflowAssemblyError) -> Self { + Self::Workflow(error) + } +} + impl From for EngineError { fn from(error: auths_author::AuthorError) -> Self { Self::Author(error) @@ -4189,40 +4100,49 @@ mod tests { ) .unwrap(); let mut builder = WorkflowProofBuilderV1::new(); - for grant in bundle.grants() { - let index = builder.grants.len(); + for (position, grant) in bundle.grants().iter().enumerate() { let grant_identifier = auths_codec::grant_id(grant.statement()).unwrap(); let ids = bundle .bindings() .iter() - .find(|binding| binding.statement() == StatementRef::Grant(grant_identifier)) + .find(|binding| { + binding.statement() == auths_model::StatementRef::Grant(grant_identifier) + }) .unwrap() .evidence(); - builder.grants.push(GrantProofMaterial { - grant: grant.clone(), - evidence: bundle - .evidence() - .iter() - .filter(|evidence| ids.contains(&evidence.id())) - .cloned() - .collect(), - }); - assert_eq!(builder.grants.len(), index + 1); + let index = builder.inner.push_grant(grant.clone()).unwrap(); + assert_eq!(index, position); + for evidence in bundle + .evidence() + .iter() + .filter(|evidence| ids.contains(&evidence.id())) + { + builder + .inner + .bind_grant_evidence(index, evidence.clone()) + .unwrap(); + } } let action = bundle.actions().first().unwrap(); let action_identifier = auths_codec::action_id(action.envelope()).unwrap(); let ids = bundle .bindings() .iter() - .find(|binding| binding.statement() == StatementRef::Action(action_identifier)) + .find(|binding| { + binding.statement() == auths_model::StatementRef::Action(action_identifier) + }) .unwrap() .evidence(); - builder.action_evidence = bundle + for evidence in bundle .evidence() .iter() .filter(|evidence| ids.contains(&evidence.id())) - .cloned() - .collect(); + { + builder + .inner + .bind_action_evidence(evidence.clone()) + .unwrap(); + } let artifacts = builder .finish_native( &auths_codec::encode_signed_action(action).unwrap(), diff --git a/compliance.toml b/compliance.toml index c0c156b1..9e7a3372 100644 --- a/compliance.toml +++ b/compliance.toml @@ -148,17 +148,17 @@ layer = "product" path = "product/integrations/auths-custody" core_apis = ["auths-author", "auths-model"] protocol_versions = ["auths-proof/v1"] -wire_objects = ["SigningIntent"] +wire_objects = ["ProviderSigningResponse", "SigningIntent"] fixture_suites = [] principal_families = ["external-custody"] signature_families = ["provider-selected"] profiles = [] transports = [] -configuration_inputs = ["key-descriptor", "signature-suite", "signing-preimage"] -security_state = ["external-key-handle", "signing-preimage"] +configuration_inputs = ["key-descriptor", "principal", "request-id", "signature-suite", "signing-preimage", "transaction-digest"] +security_state = ["external-key-handle", "provider-response-binding", "signing-preimage"] [packages.auths-custody.claims] -proof-author-or-assembler = ["product/integrations/auths-custody/src/lib.rs#mismatched_provider_transaction_cannot_produce_a_signed_object"] +proof-author-or-assembler = ["product/integrations/auths-custody/src/lib.rs#mismatched_provider_transaction_cannot_produce_a_signed_object", "product/integrations/auths-custody/src/lib.rs#provider_response_binds_request_principal_descriptor_and_transaction"] [packages.auths-deployment] kind = "cargo" @@ -1126,20 +1126,21 @@ demo-conformance-fixture = ["core/crates/auths-proof/src/lib.rs#facade_returns_n kind = "cargo" layer = "bindings" path = "bindings/python" -core_apis = [] +core_apis = ["auths-author", "auths-codec", "auths-did-keri", "auths-did-key", "auths-identity", "auths-identity-raw-key", "auths-model", "auths-ports", "auths-raw-key", "auths-registries", "auths-signature", "auths-signature-ed25519", "auths-verifier"] protocol_versions = ["auths-proof/v1"] -wire_objects = ["CanonicalAction", "PortableVerificationResult", "ProofBundle", "VerifierContext"] -fixture_suites = ["core/fixtures/v1"] +wire_objects = ["ActionEnvelope", "AuthorizationPlan", "CanonicalAction", "GrantStatement", "GrantStatusSnapshot", "GrantStatusStatement", "PortableVerificationResult", "PrincipalStatusSnapshot", "PrincipalStatusStatement", "ProofBundle", "SignedAction", "SignedGrant", "SignedGrantStatus", "SignedPrincipalStatus", "VerifierContext"] +fixture_suites = ["bindings/python/differential-fixtures-v1.json", "core/fixtures/v1"] principal_families = ["self-contained-v1"] signature_families = ["ed25519-v1", "p256-sha256-v1"] -profiles = ["core-corpus"] +profiles = ["auths.mcp/1", "core-corpus"] transports = [] -configuration_inputs = ["executed-verifier-configuration", "required-verifier-configuration"] -security_state = [] +configuration_inputs = ["approval-plan-commitment", "approval-policy-commitment", "executed-verifier-configuration", "required-verifier-configuration", "trusted-authority-configuration", "trusted-context-template"] +security_state = ["approval-provider", "delegated-authority-chain", "profile-owned-action", "sealed-plan-command", "signer-transaction-lifecycle", "typed-control-evidence"] [packages.auths-proof-python.claims] -core-wire-consumer = ["bindings/python/tests/test_api.py#test_portable_decoder_rejects_shape_version_and_trailing_data"] -language-binding = ["bindings/python/tests/test_api.py#test_configuration_mismatch_reports_required_and_executed_commitments"] +core-wire-consumer = ["bindings/python/tests/test_api.py#test_native_result_parser_preserves_decode_failure_codes", "bindings/python/tests/test_mcp_workflow.py#test_shared_full_workflow_projection_matches_native_python", "bindings/python/tests/test_native_authoring.py#test_child_planning_matches_the_shared_rust_typescript_fixture", "bindings/python/tests/test_workflow.py#test_attach_and_delegate_use_native_authority_without_protocol_bytes"] +language-binding = ["bindings/python/tests/test_api.py#test_configuration_mismatch_has_no_authorization_handle", "bindings/python/tests/test_mcp_workflow.py#test_decision_inspection_and_diagnostic_verification_stay_inert", "bindings/python/tests/test_mcp_workflow.py#test_mcp_plan_commitment_binds_order_and_exact_membership", "bindings/python/tests/test_mcp_workflow.py#test_ordered_plan_prompts_once_and_releases_one_native_plan_command", "bindings/python/tests/test_native_authoring.py#test_trust_compilation_and_request_binding_stay_native", "bindings/python/tests/test_workflow.py#test_approval_fields_cannot_outlive_their_native_commitment", "bindings/python/tests/test_workflow.py#test_every_exposed_authority_dimension_fails_before_approval_when_widened", "bindings/python/tests/test_workflow.py#test_every_approval_binding_field_fails_closed", "bindings/python/tests/test_workflow.py#test_every_signer_binding_field_fails_closed"] +runtime-enforcement-boundary = ["bindings/python/tests/test_api.py#test_verified_action_has_no_python_construction_path", "bindings/python/tests/test_api.py#test_verified_action_rejects_copy_pickle_reduce_and_mutation", "bindings/python/tests/test_api.py#test_canonical_bytes_do_not_promote_to_a_capability", "bindings/python/tests/test_api.py#test_configuration_mismatch_has_no_authorization_handle", "bindings/python/tests/test_mcp_workflow.py#test_decision_inspection_and_diagnostic_verification_stay_inert", "bindings/python/tests/test_mcp_workflow.py#test_ordered_plan_failure_exposes_no_partial_command", "bindings/python/tests/test_mcp_workflow.py#test_plan_approval_response_substitution_fails_before_signing", "bindings/python/tests/test_mcp_workflow.py#test_plan_cancellation_exposes_no_partial_command", "bindings/python/tests/test_mcp_workflow.py#test_plan_mutation_after_approval_fails_before_the_next_signature", "bindings/python/tests/test_workflow.py#test_cancellation_closes_the_partial_child_and_produces_no_signature", "bindings/python/tests/test_workflow.py#test_native_transaction_is_single_use_across_approval_and_signature"] [packages.auths-proof-wasm] kind = "cargo" diff --git a/core/crates/auths-author/src/lib.rs b/core/crates/auths-author/src/lib.rs index 519280d0..0ec95e6b 100644 --- a/core/crates/auths-author/src/lib.rs +++ b/core/crates/auths-author/src/lib.rs @@ -6,26 +6,345 @@ extern crate alloc; use alloc::string::String; +use alloc::vec; use alloc::vec::Vec; use auths_authority::{AuthorScopeDecision, evaluate_author_scope_view}; use auths_codec::{ - CodecError, action_id, action_signing_preimage, domain_commitment, encode_canonical_action, - grant_id, grant_signing_preimage, grant_status_id, grant_status_signing_preimage, - principal_status_id, principal_status_signing_preimage, transaction_binding, + CodecError, action_id, action_signing_preimage, body_digest, domain_commitment, + encode_canonical_action, evidence_id, grant_id, grant_signing_preimage, grant_status_id, + grant_status_signing_preimage, plan_id, principal_status_id, principal_status_signing_preimage, + transaction_binding, }; use auths_model::{ ActionConstraint, ActionEnvelope, ActionId, AssurancePolicyId, Audience, AudienceSet, - AuthorizationPlan, BudgetCeiling, CanonicalAction, CriticalExtensions, Digest, GrantId, - GrantStatement, GrantStatusId, GrantStatusStatement, ModelError, PermissionSet, PrincipalId, - PrincipalStatusId, PrincipalStatusStatement, ProfileRef, ProofRef, ResourceId, - ScopeAuthorityView, SignatureBytes, SignatureDescriptor, SignatureEnvelope, SignedAction, - SignedGrant, SignedGrantStatus, SignedPrincipalStatus, StatusPolicy, ValidityWindow, + AuthorizationPlan, BudgetCeiling, BundleHeader, CanonicalAction, Challenge, ChannelBindingId, + CompositionRequirement, ControlBinding, CriticalExtensions, Digest, EvidenceId, EvidenceObject, + EvidenceTypeId, GrantId, GrantStatement, GrantStatusId, GrantStatusStatement, LimitKind, + MediaType, ModelError, PermissionSet, PrincipalId, PrincipalStatusId, PrincipalStatusStatement, + ProfileRef, ProofBundle, ProofRef, ResourceId, ScopeAuthorityView, SignatureBytes, + SignatureDescriptor, SignatureEnvelope, SignedAction, SignedGrant, SignedGrantStatus, + SignedPrincipalStatus, StatementRef, StatusPolicy, Timestamp, ValidityWindow, VerifierContext, VerifierLimits, grant_authority_view, scope_authority_view, }; use core::fmt; pub use auths_authority::AuthorityDimension; +/// Profile-owned action meaning paired with its verifier envelope. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PreparedAction { + canonical: CanonicalAction, + envelope: ActionEnvelope, +} + +impl PreparedAction { + /// Returns the canonical profile action. + #[must_use] + pub const fn canonical(&self) -> &CanonicalAction { + &self.canonical + } + + /// Returns the exact unsigned action envelope. + #[must_use] + pub const fn envelope(&self) -> &ActionEnvelope { + &self.envelope + } + + /// Consumes the preparation into its canonical action and envelope. + #[must_use] + pub fn into_parts(self) -> (CanonicalAction, ActionEnvelope) { + (self.canonical, self.envelope) + } +} + +/// Constructs the shared target V1 envelope for a profile-owned action. +/// +/// # Errors +/// +/// Returns a typed error if any deterministic identifier cannot be derived. +pub fn prepare_profile_action( + canonical: CanonicalAction, + audience: Audience, + actor: PrincipalId, + terminal_grant: &SignedGrant, + challenge: [u8; 32], + evaluation_time: u64, +) -> Result { + let proof_ref = ProofRef::new(challenge); + let plan = AuthorizationPlan::proof(proof_ref); + let envelope = ActionEnvelope::new( + canonical.profile().clone(), + canonical.media_type().clone(), + body_digest(canonical.body()), + canonical.permission().clone(), + canonical.requested_budget().cloned(), + audience, + Challenge::new(challenge), + ValidityWindow::new( + Timestamp::new(evaluation_time), + Timestamp::new(evaluation_time), + )?, + actor, + Some(grant_id(terminal_grant.statement())?), + plan_id(&plan)?, + ChannelBindingId::parse("none-v1")?, + proof_ref, + Vec::new(), + CriticalExtensions::empty(), + ); + Ok(PreparedAction { + canonical, + envelope, + }) +} + +#[derive(Clone, Debug)] +struct GrantProofMaterial { + grant: SignedGrant, + evidence: Vec, +} + +/// Native-owned result of exact proof and request-context assembly. +#[derive(Clone, Debug)] +pub struct WorkflowAuthorizationArtifacts { + proof: ProofBundle, + context: VerifierContext, +} + +impl WorkflowAuthorizationArtifacts { + /// Returns the assembled proof bundle. + #[must_use] + pub const fn proof(&self) -> &ProofBundle { + &self.proof + } + + /// Returns the request-bound verifier context. + #[must_use] + pub const fn context(&self) -> &VerifierContext { + &self.context + } +} + +/// Bounded collector for signed grants and their public control evidence. +#[derive(Clone, Debug)] +pub struct WorkflowProofBuilder { + grants: Vec, + action_evidence: Vec, + limits: VerifierLimits, +} + +impl WorkflowProofBuilder { + /// Creates a collector using the deployment verifier limits. + #[must_use] + pub fn new() -> Self { + Self { + grants: Vec::new(), + action_evidence: Vec::new(), + limits: VerifierLimits::default_deployment(), + } + } + + /// Appends one signed grant and returns its evidence-binding index. + /// + /// # Errors + /// + /// Returns a collection-limit error before retaining excessive material. + pub fn push_grant(&mut self, grant: SignedGrant) -> Result { + if self.grants.len() >= self.limits.get(LimitKind::Grants) { + return Err(WorkflowAssemblyError::CollectionLimit); + } + let index = self.grants.len(); + self.grants.push(GrantProofMaterial { + grant, + evidence: Vec::new(), + }); + Ok(index) + } + + /// Binds one addressed evidence object to a grant. + /// + /// # Errors + /// + /// Returns an invalid-index or collection-limit error. + pub fn bind_grant_evidence( + &mut self, + index: usize, + evidence: EvidenceObject, + ) -> Result<(), WorkflowAssemblyError> { + let material = self + .grants + .get_mut(index) + .ok_or(WorkflowAssemblyError::InvalidGrantIndex)?; + if material.evidence.len() >= self.limits.get(LimitKind::EvidenceObjects) { + return Err(WorkflowAssemblyError::CollectionLimit); + } + material.evidence.push(evidence); + Ok(()) + } + + /// Binds one addressed evidence object to the signed action. + /// + /// # Errors + /// + /// Returns a collection-limit error before retaining excessive material. + pub fn bind_action_evidence( + &mut self, + evidence: EvidenceObject, + ) -> Result<(), WorkflowAssemblyError> { + if self.action_evidence.len() >= self.limits.get(LimitKind::EvidenceObjects) { + return Err(WorkflowAssemblyError::CollectionLimit); + } + self.action_evidence.push(evidence); + Ok(()) + } + + /// Assembles the proof and exact request-bound verifier context. + /// + /// # Errors + /// + /// Returns a typed failure for inconsistent bindings or invalid model data. + pub fn finish( + &self, + action: &SignedAction, + canonical: &CanonicalAction, + context: &VerifierContext, + ) -> Result { + let plan = AuthorizationPlan::proof(action.envelope().proof_ref()); + let exact_plan = plan_id(&plan)?; + if exact_plan != action.envelope().authorization_plan() { + return Err(WorkflowAssemblyError::ActionPlanMismatch); + } + let mut evidence = Vec::new(); + let mut bindings = Vec::new(); + for material in &self.grants { + let ids = unique_evidence(&mut evidence, &material.evidence); + if !ids.is_empty() { + bindings.push(ControlBinding::new( + StatementRef::Grant(grant_id(material.grant.statement())?), + ids, + )?); + } + } + let action_ids = unique_evidence(&mut evidence, &self.action_evidence); + if !action_ids.is_empty() { + bindings.push(ControlBinding::new( + StatementRef::Action(action_id(action.envelope())?), + action_ids, + )?); + } + if evidence.len() > self.limits.get(LimitKind::EvidenceObjects) { + return Err(WorkflowAssemblyError::CollectionLimit); + } + let proof = ProofBundle::new( + BundleHeader::v1(), + self.grants.iter().map(|item| item.grant.clone()).collect(), + vec![action.clone()], + plan, + evidence, + bindings, + Vec::new(), + Vec::new(), + Vec::new(), + Some(canonical.body().to_vec()), + )?; + let context = context + .for_request( + action.envelope().audience().clone(), + action.envelope().challenge(), + action.envelope().validity().not_before(), + )? + .with_composition(CompositionRequirement::exact(exact_plan))?; + Ok(WorkflowAuthorizationArtifacts { proof, context }) + } +} + +impl Default for WorkflowProofBuilder { + fn default() -> Self { + Self::new() + } +} + +/// Constructs a content-addressed evidence object from typed public bytes. +/// +/// # Errors +/// +/// Returns a typed failure for invalid identifiers, media, or evidence bytes. +pub fn address_evidence( + evidence_type: EvidenceTypeId, + media_type: MediaType, + bytes: Vec, +) -> Result { + let unaddressed = EvidenceObject::new( + EvidenceId::new([0; 32]), + evidence_type.clone(), + media_type.clone(), + bytes.clone(), + )?; + Ok(EvidenceObject::new( + evidence_id(&unaddressed)?, + evidence_type, + media_type, + bytes, + )?) +} + +fn unique_evidence(all: &mut Vec, additions: &[EvidenceObject]) -> Vec { + let mut ids = Vec::with_capacity(additions.len()); + for object in additions { + if !all.iter().any(|candidate| candidate.id() == object.id()) { + all.push(object.clone()); + } + if !ids.contains(&object.id()) { + ids.push(object.id()); + } + } + ids +} + +/// Failure to prepare an action or assemble its authorization proof. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WorkflowAssemblyError { + /// A target V1 model invariant was violated. + Model(ModelError), + /// Deterministic encoding or identifier derivation failed. + Codec(CodecError), + /// A bounded material collection exceeded deployment limits. + CollectionLimit, + /// Evidence targeted a grant index that does not exist. + InvalidGrantIndex, + /// The signed action did not bind the derived authorization plan. + ActionPlanMismatch, +} + +impl From for WorkflowAssemblyError { + fn from(error: ModelError) -> Self { + Self::Model(error) + } +} + +impl From for WorkflowAssemblyError { + fn from(error: CodecError) -> Self { + Self::Codec(error) + } +} + +impl fmt::Display for WorkflowAssemblyError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Model(_) => formatter.write_str("invalid authorization workflow value"), + Self::Codec(_) => formatter.write_str("could not derive authorization binding"), + Self::CollectionLimit => formatter.write_str("authorization material exceeds limits"), + Self::InvalidGrantIndex => formatter.write_str("grant evidence index is invalid"), + Self::ActionPlanMismatch => { + formatter.write_str("signed action does not bind its authorization plan") + } + } + } +} + +#[cfg(feature = "std")] +impl std::error::Error for WorkflowAssemblyError {} + /// Requested child authority before issuer/linkage fields are derived. #[derive(Clone, Debug, Eq, PartialEq)] pub struct GrantRequest { @@ -818,7 +1137,8 @@ mod tests { use super::*; use auths_model::{ Audience, CapabilityId, CriticalExtension, CriticalExtensions, ExtensionId, LimitKind, - Permission, ProfileId, ResourceId, StatusPolicy, Timestamp, + MediaType, Permission, PrincipalMethodId, ProfileId, ResourceId, SignatureSuiteId, + StatusPolicy, Timestamp, VerificationMethod, }; fn permissions(resource: &str) -> PermissionSet { @@ -847,6 +1167,20 @@ mod tests { ) } + fn signed_parent() -> SignedGrant { + SignedGrant::new( + parent(), + SignatureEnvelope::new( + SignatureDescriptor::new( + PrincipalMethodId::parse("raw-key-v1").unwrap(), + VerificationMethod::parse("did:key:root").unwrap(), + SignatureSuiteId::parse("ed25519-v1").unwrap(), + ), + SignatureBytes::new(vec![1; 64]).unwrap(), + ), + ) + } + fn request(permission: PermissionSet) -> GrantRequest { GrantRequest::new( PrincipalId::parse("did:key:agent").unwrap(), @@ -950,6 +1284,69 @@ mod tests { Err(PlanningError::InvalidPlan) ); } + + #[test] + fn profile_action_preparation_derives_exact_shared_bindings() { + let grant = signed_parent(); + let canonical = CanonicalAction::new( + grant.statement().profile().clone(), + MediaType::parse("application/json").unwrap(), + br#"{"value":1}"#.to_vec(), + grant.statement().permissions().as_slice()[0].clone(), + None, + ) + .unwrap(); + let prepared = prepare_profile_action( + canonical.clone(), + Audience::parse("deploy://production").unwrap(), + grant.statement().subject().clone(), + &grant, + [7; 32], + 42, + ) + .unwrap(); + assert_eq!(prepared.canonical(), &canonical); + assert_eq!( + prepared.envelope().terminal_grant(), + Some(grant_id(grant.statement()).unwrap()) + ); + assert_eq!(prepared.envelope().challenge(), Challenge::new([7; 32])); + assert_eq!( + prepared.envelope().validity().not_before(), + Timestamp::new(42) + ); + } + + #[test] + fn proof_builder_bounds_grants_before_retaining_overflow() { + let mut builder = WorkflowProofBuilder::new(); + let limit = VerifierLimits::default_deployment().get(LimitKind::Grants); + for _ in 0..limit { + builder.push_grant(signed_parent()).unwrap(); + } + assert_eq!( + builder.push_grant(signed_parent()), + Err(WorkflowAssemblyError::CollectionLimit) + ); + } + + #[test] + fn public_evidence_is_content_addressed_deterministically() { + let first = address_evidence( + EvidenceTypeId::parse("raw-key-v1").unwrap(), + MediaType::parse("application/vnd.auths.raw-key.v1").unwrap(), + vec![1, 2, 3], + ) + .unwrap(); + let second = address_evidence( + EvidenceTypeId::parse("raw-key-v1").unwrap(), + MediaType::parse("application/vnd.auths.raw-key.v1").unwrap(), + vec![1, 2, 3], + ) + .unwrap(); + assert_eq!(first.id(), second.id()); + assert_ne!(first.id(), EvidenceId::new([0; 32])); + } #[test] fn approval_policy_commitment_separates_every_field() { let baseline = diff --git a/core/crates/auths-verifier/src/lib.rs b/core/crates/auths-verifier/src/lib.rs index 0fdd93d4..83b8e4c8 100644 --- a/core/crates/auths-verifier/src/lib.rs +++ b/core/crates/auths-verifier/src/lib.rs @@ -292,6 +292,46 @@ pub struct VerifiedAction { work_units: u64, } +/// Portable decision data paired with the sealed action from the same run. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SealedVerificationResult { + portable: PortableVerificationResult, + cbor: Vec, + action: Option>, +} + +impl SealedVerificationResult { + /// Returns the language-neutral decision data. + #[must_use] + pub const fn portable(&self) -> &PortableVerificationResult { + &self.portable + } + + /// Returns the canonical portable decision bytes. + #[must_use] + pub fn cbor(&self) -> &[u8] { + &self.cbor + } + + /// Returns the sealed action only when the decision is authorized. + #[must_use] + pub fn action(&self) -> Option<&VerifiedAction> { + self.action.as_deref() + } + + /// Consumes the result into decision data, canonical bytes, and capability. + #[must_use] + pub fn into_parts( + self, + ) -> ( + PortableVerificationResult, + Vec, + Option>, + ) { + (self.portable, self.cbor, self.action) + } +} + impl VerifiedAction { /// Returns exact profile-canonical bytes and derived meaning. #[must_use] @@ -851,6 +891,27 @@ pub fn verify_v1( trusted_context_cbor: &[u8], registries: &ImmutableRegistries<'_>, ) -> Result, CodecError> { + verify_v1_sealed( + proof_cbor, + canonical_action_cbor, + trusted_context_cbor, + registries, + ) + .map(|result| result.cbor) +} + +/// Executes the byte-oriented V1 ABI without discarding native authority. +/// +/// # Errors +/// +/// Returns [`CodecError`] only if the constructed portable result cannot be +/// canonically encoded. +pub fn verify_v1_sealed( + proof_cbor: &[u8], + canonical_action_cbor: &[u8], + trusted_context_cbor: &[u8], + registries: &ImmutableRegistries<'_>, +) -> Result { let proof_input_digest = body_digest(proof_cbor); let action_input_digest = body_digest(canonical_action_cbor); let input_resources = VerificationResources::new( @@ -877,7 +938,7 @@ pub fn verify_v1( None, registries.configuration_id(), ); - return encode_verification_result(&result); + return seal_portable(result, None); } }; let canonical_action = match decode_canonical_action(canonical_action_cbor, context.limits()) { @@ -895,15 +956,12 @@ pub fn verify_v1( Some(context.configuration()), registries.configuration_id(), ); - return encode_verification_result(&result); + return seal_portable(result, None); } }; - encode_verification_result(&verify_portable( - proof_cbor, - &canonical_action, - &context, - registries, - )) + let (portable, action) = + verify_portable_sealed(proof_cbor, &canonical_action, &context, registries); + seal_portable(portable, action) } /// Runs the complete `verify_v1` contract and returns a canonically encodable @@ -916,6 +974,16 @@ pub fn verify_portable( context: &VerifierContext, registries: &ImmutableRegistries<'_>, ) -> PortableVerificationResult { + verify_portable_sealed(proof_bytes, canonical_action, context, registries).0 +} + +#[allow(clippy::too_many_lines)] +fn verify_portable_sealed( + proof_bytes: &[u8], + canonical_action: &CanonicalAction, + context: &VerifierContext, + registries: &ImmutableRegistries<'_>, +) -> (PortableVerificationResult, Option>) { let action_bytes = encode_canonical_action(canonical_action).unwrap_or_default(); let context_bytes = encode_verifier_context(context).unwrap_or_default(); let proof_input_digest = body_digest(proof_bytes); @@ -936,17 +1004,20 @@ pub fn verify_portable( let decoded = match decode_proof(proof_bytes, context) { Ok(decoded) => decoded, Err(failure) => { - return portable_failure( - failure, - VerificationStage::Decode, - proof_input_digest, - action_digest, - public_context_digest, + return ( + portable_failure( + failure, + VerificationStage::Decode, + proof_input_digest, + action_digest, + public_context_digest, + None, + resources, + context.accepted_registries().manifest_id(), + Some(context.configuration()), + local_configuration, + ), None, - resources, - context.accepted_registries().manifest_id(), - Some(context.configuration()), - local_configuration, ); } }; @@ -977,17 +1048,20 @@ pub fn verify_portable( let resolved = match resolve_proof(decoded, context) { Ok(resolved) => resolved, Err(failure) => { - return portable_failure( - failure, - VerificationStage::Resolve, - proof_input_digest, - action_digest, - public_context_digest, + return ( + portable_failure( + failure, + VerificationStage::Resolve, + proof_input_digest, + action_digest, + public_context_digest, + None, + resources, + context.accepted_registries().manifest_id(), + Some(context.configuration()), + local_configuration, + ), None, - resources, - context.accepted_registries().manifest_id(), - Some(context.configuration()), - local_configuration, ); } }; @@ -995,17 +1069,20 @@ pub fn verify_portable( let controlled = match verify_principal_control(resolved, context, registries) { Ok(controlled) => controlled, Err(failure) => { - return portable_failure( - failure, - VerificationStage::PrincipalControl, - proof_input_digest, - action_digest, - public_context_digest, - resolved_plan, - resources, - context.accepted_registries().manifest_id(), - Some(context.configuration()), - local_configuration, + return ( + portable_failure( + failure, + VerificationStage::PrincipalControl, + proof_input_digest, + action_digest, + public_context_digest, + resolved_plan, + resources, + context.accepted_registries().manifest_id(), + Some(context.configuration()), + local_configuration, + ), + None, ); } }; @@ -1039,7 +1116,7 @@ pub fn verify_portable( resources.plan_depth(), authority.work_units, ); - finalize_portable(PortableVerificationResult::new( + let portable = finalize_portable(PortableVerificationResult::new( VerificationDecision::Authorized, VerificationStage::Complete, VerificationCode::Authorized, @@ -1047,14 +1124,15 @@ pub fn verify_portable( action_digest, public_context_digest, Some(authority.plan_id), - authority.authorized_branches, - authority.assurance, - authority.assurance_satisfactions, + authority.authorized_branches.clone(), + authority.assurance.clone(), + authority.assurance_satisfactions.clone(), resources, context.accepted_registries().manifest_id(), Some(context.configuration()), local_configuration, - )) + )); + (portable, Some(Box::new(bind_verified_action(authority)))) } Err(failure) => { let resources = VerificationResources::new( @@ -1066,22 +1144,37 @@ pub fn verify_portable( resources.plan_depth(), authority_meter.used, ); - portable_failure( - failure, - VerificationStage::Authority, - proof_input_digest, - action_digest, - public_context_digest, - resolved_plan, - resources, - context.accepted_registries().manifest_id(), - Some(context.configuration()), - local_configuration, + ( + portable_failure( + failure, + VerificationStage::Authority, + proof_input_digest, + action_digest, + public_context_digest, + resolved_plan, + resources, + context.accepted_registries().manifest_id(), + Some(context.configuration()), + local_configuration, + ), + None, ) } } } +fn seal_portable( + portable: PortableVerificationResult, + action: Option>, +) -> Result { + let cbor = encode_verification_result(&portable)?; + Ok(SealedVerificationResult { + portable, + cbor, + action, + }) +} + #[allow(clippy::too_many_arguments)] fn portable_failure( failure: VerificationFailure, @@ -2897,6 +2990,56 @@ mod tests { ); } + #[test] + fn sealed_portable_result_releases_authority_only_on_success() { + let fixture = target_fixture(false); + let method = RawKeyMethod::new().unwrap(); + let suite = Ed25519Suite::new().unwrap(); + let methods: [&dyn auths_ports::PrincipalMethod; 1] = [&method]; + let suites: [&dyn auths_ports::SignatureSuite; 1] = [&suite]; + let registries = ImmutableRegistries::new(&methods, &suites).unwrap(); + let action_bytes = auths_codec::encode_canonical_action(&fixture.canonical).unwrap(); + let context_bytes = auths_codec::encode_verifier_context(&fixture.context).unwrap(); + + let authorized = + verify_v1_sealed(&fixture.bytes, &action_bytes, &context_bytes, ®istries).unwrap(); + assert_eq!( + authorized.cbor(), + verify_v1(&fixture.bytes, &action_bytes, &context_bytes, ®istries).unwrap() + ); + assert_eq!( + authorized.action().unwrap().canonical_action(), + &fixture.canonical + ); + + let denied = + verify_v1_sealed(&fixture.bytes, b"invalid", &context_bytes, ®istries).unwrap(); + assert_eq!(denied.portable().decision(), VerificationDecision::Denied); + assert!(denied.action().is_none()); + + let no_methods: [&dyn auths_ports::PrincipalMethod; 0] = []; + let no_suites: [&dyn auths_ports::SignatureSuite; 0] = []; + let empty_registries = ImmutableRegistries::new(&no_methods, &no_suites).unwrap(); + let unsupported_context = fixture + .context + .with_configuration(empty_registries.configuration_id()) + .unwrap(); + let unsupported_context = + auths_codec::encode_verifier_context(&unsupported_context).unwrap(); + let indeterminate = verify_v1_sealed( + &fixture.bytes, + &action_bytes, + &unsupported_context, + &empty_registries, + ) + .unwrap(); + assert_eq!( + indeterminate.portable().decision(), + VerificationDecision::Indeterminate + ); + assert!(indeterminate.action().is_none()); + } + fn verify_composition_fixture( fixture: &auths_testkit::CorpusFixture, minimum_authorized_branches: u16, diff --git a/docs/plans/PYTHON_ATTACH_DELEGATE_MILESTONE_B.md b/docs/plans/PYTHON_ATTACH_DELEGATE_MILESTONE_B.md new file mode 100644 index 00000000..b6498145 --- /dev/null +++ b/docs/plans/PYTHON_ATTACH_DELEGATE_MILESTONE_B.md @@ -0,0 +1,112 @@ +# Python attach and delegation workflow + +**Status:** Milestone B implemented; release review pending + +**Scope:** AP35-PR4 through AP35-PR6 + +## UX + +The application supplies typed provider ports and native Auths values: + +```python +async with AuthsClient( + signer=parent_signer, + trusted_authority=trusted_authority, +) as auths: + parent = await auths.attach_agent( + name="research-agent", + profile=Profile("auths.mcp", 1), + authority=signed_root_grant, + approval=approval, + ) + + async with await parent.delegate( + name="records-child", + authority=DelegatedAuthority(...), + signer=child_signer, + ) as child: + review(child.authority, child.delegation) +``` + +The workflow exposes authority summaries, attenuation differences, and +over-granting warnings. It does not expose grant CBOR, build signing +preimages in Python, accept private keys, or bundle a production signer. + +Provider cancellation propagates as `asyncio.CancelledError`. Provider +failures cross the SDK only as typed, sanitized Auths errors. Root and child +signers are closed exactly once on normal exit, failure, cancellation, and +partial construction. + +## Architecture + +```text ++------------------------ Python application -------------------------+ +| signer + approval ports | attach agent | request narrower authority | ++-------------------------+--------------+----------------------------+ + | + v ++---------------------- Python orchestration -------------------------+ +| async calls | cancellation | ownership | sanitized provider errors | ++--------------------------+------------------------------------------+ + | + v ++------------------------ native ABI v1 ------------------------------+ +| typed identity | root binding | attenuation | transaction binding | +| authority summary | exact request phases | signed-grant completion | ++--------------------------+------------------------------------------+ + | + v ++---------------- canonical Rust semantic owners --------------------+ +| auths-author | auths-model | auths-codec | auths-sdk | ++---------------------------------------------------------------------+ +``` + +Python owns callback scheduling and lifetime management. Rust owns principal +and descriptor parsing, trusted-authority checks, root and delegated grant +bindings, authority projection, every attenuation dimension, policy +commitments, signing preimages, transaction identity, response matching, and +signed-object construction. + +## APIs + +- `Signer` is an async `Protocol` with a typed public identity, one exact + signing operation, and deterministic `aclose`. +- `ApprovalProvider` is an async `Protocol` over immutable approval requests + and typed responses. +- `Approval` builds one of the four AP-SPEC-035 modes from a Rust-owned policy + commitment: grant-only, risk-based, every-action, or registered custom. +- `TrustedAuthority` binds an authority identifier, native root principal, + native trusted context, and exact required approval policy. +- `AuthsClient` owns the root signer and invalidates every attached agent when + closed. +- `attach_agent` accepts a native signed grant or a typed signed-grant source, + then asks Rust to bind the root, subject, and profile before returning an + agent. +- `delegate` accepts a closed `DelegatedAuthority` type. Profile and critical + extensions are inherited; issuer and parent linkage are derived by Rust. +- Native signing transactions move once from awaiting approval to awaiting + signature to terminal. A mismatched, rejected, expired, duplicated, failed, + or cancelled response cannot be reused. + +This milestone does not assemble proof bundles, authorize profile actions, +mint profile commands, call gateways, ship provider adapters, or promote the +package beyond its verifier-binding release claim. + +## Task list + +- [x] Add typed signer, approval, authority-source, response, and error contracts. +- [x] Add native principal descriptors and Rust-owned approval commitments. +- [x] Add native exact-response signing transactions with terminal-state enforcement. +- [x] Add native trusted-authority, root-grant, and delegated-grant bindings. +- [x] Add `AuthsClient`, async context management, and deterministic signer cleanup. +- [x] Add `attach_agent` with native root-authority summaries. +- [x] Add `delegate` with native attenuation, semantic diff, warnings, approval, and signing. +- [x] Prove mismatch, widening, cancellation, duplicate, expiry, and cleanup behavior. +- [x] Add typing, installed-wheel, differential, ABI, architecture, and compliance evidence. + +## Exit + +A Python application can attach an agent and delegate narrower authority +without handling protocol bytes or private keys. No provider response can be +substituted across a principal, descriptor, policy, request, transaction, or +provider call, and every failed partial workflow ends in a closed state. diff --git a/docs/plans/PYTHON_ELITE_SDK_SPEC.md b/docs/plans/PYTHON_ELITE_SDK_SPEC.md index 357f665b..64f90c79 100644 --- a/docs/plans/PYTHON_ELITE_SDK_SPEC.md +++ b/docs/plans/PYTHON_ELITE_SDK_SPEC.md @@ -1,7 +1,7 @@ # Python Elite SDK Product Specification -**Status:** Proposed execution specification -**Baseline:** `aae3609` on 2026-08-10 +**Status:** Python implementation complete; promotion gates remain external +**Baseline:** `61bedef` on 2026-08-11 **Lifecycle:** Prelaunch; no external users or production state require backward compatibility **Product ambition:** Make `auths` credible as the “Stripe for identity and @@ -323,6 +323,7 @@ The intended Python topology is: ```text auths integrated attach/delegate/authorize path auths.identity standalone identity and authentication +auths.integrations bounded identity transport and framework ports auths.trust trust bundles, evidence, and freshness inputs auths.authority grants, delegation, plans, lifecycle links auths.approvals approval policies and provider Protocols @@ -359,14 +360,14 @@ They must not become base dependencies or sources of Auths meaning. ### PY-E0 — Freeze the elite contract and measurements -- [ ] Inventory public Python names, native classes, module topology, +- [x] Inventory public Python names, native classes, module topology, supported interpreters/platforms, and claims from the installed wheel. -- [ ] Record import, initialization, verification, plan, batch, memory, +- [x] Record import, initialization, verification, plan, batch, memory, event-loop, and wheel-size baselines. -- [ ] Freeze the error hierarchy, async policy, typing policy, exact runtime +- [x] Freeze the error hierarchy, async policy, typing policy, exact runtime contract, prelaunch clean-break policy, threat model, and exact non-goals. -- [ ] Make the scorecard in section 3 CI-readable capability metadata. -- [ ] Replace the single shared workflow projection with the CORE-E09 scenario +- [x] Make the scorecard in section 3 CI-readable capability metadata. +- [x] Replace the single shared workflow projection with the CORE-E09 scenario matrix used by Rust and TypeScript. **Exit:** implementation, evidence, promoted tier, runtime support, and product @@ -374,35 +375,35 @@ claims are independently versioned and cannot contradict one another. ### PY-E1 — Make the first 15 minutes exceptional -- [ ] Provide an installed-wheel identity quickstart and a complete protected +- [x] Provide an installed-wheel identity quickstart and a complete protected MCP action quickstart with no source-tree imports. -- [ ] Reduce setup to typed Python configuration with no raw protocol bytes, +- [x] Reduce setup to typed Python configuration with no raw protocol bytes, copied constants, manual hashes, or direct native-handle management. -- [ ] Replace `auths.advanced` with the direct `auths.verify`, +- [x] Replace `auths.advanced` with the direct `auths.verify`, `auths.inspection`, and `auths.diagnostics` modules, deleting the old module and every stale reference in the same prelaunch cutover. -- [ ] Add obvious development-only signers, approvals, clocks, stores, and +- [x] Add obvious development-only signers, approvals, clocks, stores, and gateways under `auths.testkit`. -- [ ] Add a diagnostic report for exact wheel/native ABI agreement, native +- [x] Add a diagnostic report for exact wheel/native ABI agreement, native capabilities, adapters, trust, profiles, and configuration commitments. -- [ ] Execute every documentation snippet and both quickstarts in isolated CI. +- [x] Execute every documentation snippet and both quickstarts in isolated CI. **Exit:** a Python developer unfamiliar with Auths completes both journeys without reading Rust, TypeScript, or the protocol specification. ### PY-E2 — Build the standalone identity product -- [ ] Bind CORE-E02 and expose decoded, validated, resolved, and authenticated +- [x] Bind CORE-E02 and expose decoded, validated, resolved, and authenticated identity states under `auths.identity`. -- [ ] Ship raw-key/Ed25519 and one structurally different reference path such +- [x] Ship raw-key/Ed25519 and one structurally different reference path such as P-256 or resolver-backed identity to prove the port. -- [ ] Define async-capable method, suite, and resolver `Protocol` contracts +- [x] Define async-capable method, suite, and resolver `Protocol` contracts with exact version, purpose, timeout, and cancellation behavior. -- [ ] Add rotation, history, composite, threshold, and hybrid/post-quantum +- [x] Add rotation, history, composite, threshold, and hybrid/post-quantum conformance fixtures without claiming every adapter is maintained. -- [ ] Add one explicit validated-identity-to-authority bridge that preserves +- [x] Add one explicit validated-identity-to-authority bridge that preserves method, suite, purpose, provenance, and assurance. -- [ ] Prove the identity-only wheel import graph contains no authority, +- [x] Prove the identity-only wheel import graph contains no authority, approval, profile, or runtime dependency. **Exit:** Python users can adopt Auths identity and authentication alone, then @@ -410,17 +411,17 @@ add permissions without changing identity meaning. ### PY-E3 — Reach full profile and plan breadth -- [ ] Add a second maintained profile that differs materially from MCP in +- [x] Add a second maintained profile that differs materially from MCP in action shape, resource projection, gateway, and receipt behavior. -- [ ] Bind CORE-E07 and implement `auths.profile_kit` only after the second +- [x] Bind CORE-E07 and implement `auths.profile_kit` only after the second profile demonstrates the reusable boundary. -- [ ] Give each profile distinct action, authority, command, plan-command, +- [x] Give each profile distinct action, authority, command, plan-command, gateway, receipt, and error types. -- [ ] Add Rust-owned all-of, any-of, and threshold proof plans alongside exact +- [x] Add Rust-owned all-of, any-of, and threshold proof plans alongside exact ordered profile plans. -- [ ] Provide semantic mutation and gateway conformance suites for maintained +- [x] Provide semantic mutation and gateway conformance suites for maintained and application profiles. -- [ ] Test cross-profile, cross-version, order, duplicate, omission, append, +- [x] Test cross-profile, cross-version, order, duplicate, omission, append, substitution, partial failure, and command-forgery attacks. **Exit:** Python is no longer “the MCP binding”; it is a sound platform for @@ -428,15 +429,15 @@ multiple closed action domains. ### PY-E4 — Productize trust, status, and lifecycle -- [ ] Bind CORE-E03 as typed trust bundles, status snapshots, assurance rules, +- [x] Bind CORE-E03 as typed trust bundles, status snapshots, assurance rules, rotation, compromise, historical state, and freshness inputs. -- [ ] Add resolver and evidence-provider `Protocol` contracts with provenance, +- [x] Add resolver and evidence-provider `Protocol` contracts with provenance, timeout, cancellation, size, redirect, cache, and SSRF limits. -- [ ] Expose principal-status and grant-status authoring without raw bytes or a +- [x] Expose principal-status and grant-status authoring without raw bytes or a generic signing operation. -- [ ] Make missing, stale, contradictory, and unavailable evidence produce +- [x] Make missing, stale, contradictory, and unavailable evidence produce stable denied or indeterminate results. -- [ ] Add offline evidence bundles and lifecycle recipes for delegation +- [x] Add offline evidence bundles and lifecycle recipes for delegation withdrawal, identity rotation, compromise, and clean prelaunch policy replacement. @@ -445,17 +446,17 @@ rotating identity merely to withdraw delegated authority. ### PY-E5 — Perfect authoring, delegation, approval, and cleanup -- [ ] Consolidate attach, delegation, action authoring, and plan orchestration +- [x] Consolidate attach, delegation, action authoring, and plan orchestration over CORE-E01 while keeping Python callback scheduling explicit. -- [ ] Expose review data independently before any approval request. -- [ ] Support no approval, grant-only, every-action, risk-gated, threshold, +- [x] Expose review data independently before any approval request. +- [x] Support no approval, grant-only, every-action, risk-gated, threshold, and exact plan-once policies from one immutable committed model. -- [ ] Prove non-widening across permission, resource, audience, validity, +- [x] Prove non-widening across permission, resource, audience, validity, budget, status, assurance, profile, extension, and delegation depth. -- [ ] Specify cancellation and cleanup for provider rejection, timeout, +- [x] Specify cancellation and cleanup for provider rejection, timeout, duplicate callback, task cancellation, exception groups, and process shutdown. -- [ ] Guarantee that partial workflows expose no reusable native transaction +- [x] Guarantee that partial workflows expose no reusable native transaction or profile command. **Exit:** async Python failures cannot bypass attenuation, leak reusable @@ -463,17 +464,17 @@ authority, or leave ambiguous cleanup ownership. ### PY-E6 — Close enforcement, replay, budgets, and receipts -- [ ] Bind CORE-E08 and expose challenge, replay, budget, receipt, store, and +- [x] Bind CORE-E08 and expose challenge, replay, budget, receipt, store, and closed-executor `Protocol` contracts. -- [ ] Model reserved, executing, executed, failed, duplicate, exhausted, +- [x] Model reserved, executing, executed, failed, duplicate, exhausted, unavailable, cancelled, and outcome-unknown states exhaustively. -- [ ] Require idempotency and reconciliation behavior from effectful gateways; +- [x] Require idempotency and reconciliation behavior from effectful gateways; never imply remote atomicity or exactly-once execution. -- [ ] Ship an in-memory test implementation and one separately packaged +- [x] Ship an in-memory test implementation and one separately packaged durable reference store. -- [ ] Prove denied, indeterminate, forged, mismatched, expired, replayed, and +- [x] Prove denied, indeterminate, forged, mismatched, expired, replayed, and exhausted commands cause no gateway call and no state mutation. -- [ ] Bind profile-owned receipts back to exact command, authority, context, +- [x] Bind profile-owned receipts back to exact command, authority, context, state claim, and observed provider outcome. **Exit:** Python services can operate authorized effects safely under retries, @@ -481,16 +482,16 @@ concurrency, crashes, and uncertain remote outcomes. ### PY-E7 — Make Python integrations replaceable and certifiable -- [ ] Version signer, approval, identity, suite, resolver, status, clock, +- [x] Version signer, approval, identity, suite, resolver, status, clock, store, telemetry, gateway, transport, and framework `Protocol` contracts. -- [ ] Publish executable sync/async adapter conformance suites where the port +- [x] Publish executable sync/async adapter conformance suites where the port permits both; security workflows remain async-native. -- [ ] Maintain only a small reference set proving local, remote-custody, +- [x] Maintain only a small reference set proving local, remote-custody, resolver, durable-state, telemetry, and web-framework substitution. -- [ ] Provide recipes for one remote KMS/HSM family, one resolver family, one +- [x] Provide recipes for one remote KMS/HSM family, one resolver family, one durable store, OpenTelemetry, and FastAPI without importing them into the base wheel. -- [ ] Define third-party adapter qualification metadata, support ownership, +- [x] Define third-party adapter qualification metadata, support ownership, dependency policy, and security-claim boundaries. **Exit:** the Python ecosystem can extend Auths without a central adapter team @@ -498,16 +499,16 @@ or semantic plugins. ### PY-E8 — Deliver elite errors, typing, and observability -- [ ] Bind CORE-E06 into one stable `AuthsError` hierarchy and immutable +- [x] Bind CORE-E06 into one stable `AuthsError` hierarchy and immutable decision/explanation/inspection types. -- [ ] Publish strict mypy and Pyright fixtures for every state transition, +- [x] Publish strict mypy and Pyright fixtures for every state transition, provider port, result union, profile command, and context manager. -- [ ] Make public signatures useful in IDEs without requiring users to import +- [x] Make public signatures useful in IDEs without requiring users to import private native classes or understand handle lifetimes. -- [ ] Add OpenTelemetry-compatible hooks with no required exporter and no raw +- [x] Add OpenTelemetry-compatible hooks with no required exporter and no raw proof, key, credential, signature, or high-cardinality payload fields. -- [ ] Add a deterministic, redacted support bundle and decision timeline. -- [ ] Test `str`, `repr`, traceback, dataclass projection, logging, telemetry, +- [x] Add a deterministic, redacted support bundle and decision timeline. +- [x] Test `str`, `repr`, traceback, dataclass projection, logging, telemetry, pickle, copy, and introspection for secret and capability leakage. **Exit:** failures are actionable to Python developers and safe to share with @@ -515,37 +516,40 @@ operators, while types prevent normal misuse before runtime. ### PY-E9 — Add native throughput without changing meaning -- [ ] Bind CORE-E05 as bounded `verify_many` and plan/bundle operations that +- [x] Bind CORE-E05 as bounded `verify_many` and plan/bundle operations that preserve input order and per-item three-valued results. -- [ ] Release the GIL around pure native work and reacquire it only at explicit +- [x] Release the GIL around pure native work and reacquire it only at explicit Python callback boundaries. -- [ ] Bound buffer copies, batch size, memory, work, result count, and +- [x] Bound buffer copies, batch size, memory, work, result count, and cancellation latency. -- [ ] Make sync native calls safe from threads and async workflows safe across +- [x] Make sync native calls safe from threads and async workflows safe across task cancellation; do not hide thread pools or event loops. -- [ ] Benchmark standard CPython versions and operating systems against Phase +- [x] Benchmark standard CPython versions and operating systems against Phase 0 budgets. -- [ ] Prove batch and cached output equals independent single-item Rust, - TypeScript, and Python evaluation across CORE-E09. +- [x] Prove batch output equals independent single-item Rust, TypeScript, and + Python evaluation across CORE-E09; Python exposes no semantic result + cache. **Exit:** Python can serve high-volume decisions efficiently without acquiring a separate fast-path security model. ### PY-E10 — Make wheels and releases boring -- [ ] Enforce CORE-E10 exact ABI identities and native/package subject matching +- [x] Enforce CORE-E10 exact ABI identities and native/package subject matching at import and workflow construction. -- [ ] Qualify source distributions only if intentionally supported; otherwise +- [x] Qualify source distributions only if intentionally supported; otherwise fail installation with an accurate wheel-support message. -- [ ] Test exact release wheels on every claimed CPython, architecture, and OS +- [x] Test exact release wheels on every claimed CPython, architecture, and OS with Rust and the source tree absent. -- [ ] Keep imports side-effect-free and prove package-content, API, type, +- [x] Keep imports side-effect-free and prove package-content, API, type, license, SBOM, provenance, and claim manifests agree. -- [ ] Add current-wheel/current-native agreement fixtures and fail-closed +- [x] Add current-wheel/current-native agreement fixtures and fail-closed mismatched-artifact tests; support no cross-version runtime window. -- [ ] Complete fuzzing, hostile native-boundary tests, dependency review, - external-consumer qualification, and independent review. -- [ ] Remove superseded public surfaces directly, with automated stale-reference, +- [x] Complete repository fuzzing, hostile native-boundary tests, dependency + review, and external-consumer qualification. +- [ ] Complete independent external security and consumer review against the + exact release candidate. +- [x] Remove superseded public surfaces directly, with automated stale-reference, API-snapshot, typing, and exact-artifact checks in the same change. **Exit:** one Python release is internally coherent and installable with no @@ -627,5 +631,21 @@ exact installed wheel and public documentation: 12. install and operate on the claimed wheel matrix with exact ABI, performance, provenance, and review evidence. -Until all twelve are true, the repository may claim progress toward the elite -product, but not completion. +Until all twelve are true, the repository may describe the completed Python +elite surface, but the released cross-SDK product must not claim stable-V1, +production, certification, or independent-review status. + +### 10.1 Qualification state + +| Gate | State | Evidence or owner | +| --- | --- | --- | +| Python repository implementation | Complete | `bindings/python/sdk-capability.json`, exact public API snapshot, native ABI 2 manifest, and customer-journey matrix | +| Source behavior, strict typing, adapter, and installed-wheel qualification | Enforced in CI | `.github/workflows/python-sdk.yml` | +| Exact CPython 3.9–3.14 Linux/macOS/Windows wheel boundary | Enforced in CI | abi3 wheel matrix with the Rust toolchain removed from consumers | +| Rust/TypeScript/Python current-version parity | Complete | `bindings/customer-journey-matrix-v1.json` and generated differential fixtures | +| Independent external review | Pending | Must review the exact release candidate; implementation code cannot self-attest it | +| Stable-V1 publication and promotion | Blocked | Release owner authorization after exact CI, provenance, and external-review gates | + +The implementation may be described as the completed Python elite surface. +Publication, production readiness, and independent review remain separate +release decisions. diff --git a/docs/plans/PYTHON_FULL_WORKFLOW_MILESTONE_D.md b/docs/plans/PYTHON_FULL_WORKFLOW_MILESTONE_D.md new file mode 100644 index 00000000..42c8d916 --- /dev/null +++ b/docs/plans/PYTHON_FULL_WORKFLOW_MILESTONE_D.md @@ -0,0 +1,116 @@ +# Python Full Workflow SDK — Milestone D + +**Status:** repository-local implementation complete +**Baseline:** `940382a` +**Governing specification:** AP-SPEC-035, AP35-PR8 through AP35-PR9 +**Capability tier:** repository-local Full Workflow SDK + +## UX + +The normal MCP path adds an ordered plan without exposing protocol bytes: + +```python +approval = Approval.plan_once( + "approval.mcp-plan", + approval_provider, + max_uses=2, +) +plan = profile.plan(( + profile.call("prepare_report", {"month": "august"}), + profile.call("publish_report", {"month": "august"}), +)) +result = await agent.authorize_plan(plan) + +if result.kind == "authorized": + responses = await profile.gateway(execute).execute_plan(result.command) +``` + +The approval provider is called once for the exact ordered plan. Every member +is still signed and verified independently. A denied or indeterminate member +stops the plan and exposes no command, including commands from earlier +authorized members. Execution remains ordered and is not presented as an +atomic remote transaction. + +Advanced consumers can inspect copied commitments and run a caller-supplied +diagnostic verifier. Neither path can mint a verified action or profile +command. + +```text ++----------------------+ +-------------------------------+ +| normal workflow | | advanced evidence | +| plan -> authorize | | raw verify -> inert result | +| -> sealed plan | | inspect -> copied commitments | ++----------+-----------+ +---------------+---------------+ + | | + v v + closed MCP gateway never effect-capable +``` + +## Architecture + +```text +Python MCP facade + -> native MCP canonicalization and ordered plan commitment + -> bounded plan-once approval session + -> existing native proof assembly and verification per member + -> native sealed MCP plan command + -> profile-owned MCP gateway + +Rust fixture generator + -> one shared Full Workflow projection + -> TypeScript fixture assertion + -> Python fixture assertion + +wheel build + -> content allowlist + -> isolated consumer install + -> mypy and Pyright contracts + -> CPython 3.9/current on Linux, macOS, and Windows +``` + +Rust continues to own canonical action, plan, approval, proof, verifier, and +command meaning. Python owns callback scheduling, immutable product results, +and deterministic disposal. The package does not add a generic executor. + +An application profile kit is deliberately deferred. MCP remains a complete +profile-local vertical, and a Python profile abstraction will be considered +only after a second independently implemented Python profile supplies the +comparison evidence required by the profile/domain abstraction boundary plan. + +## APIs + +- `Approval.plan_once(...)` builds the exact committed approval mode. +- `McpProfile.plan(actions)` returns an immutable ordered `McpPlan`. +- `AttachedAgent.authorize_plan(plan)` returns `McpPlanAuthorized`, + `McpPlanDenied`, or `McpPlanIndeterminate`. +- Only `McpPlanAuthorized` carries a native `McpPlanCommand`. +- `McpGateway.execute_plan(command, idempotency_key=...)` consumes the command + before invoking the application callback for each ordered member and returns + command-bound receipts. +- `inspect_decision(result)` returns safe commitments, metrics, and log fields. +- `create_diagnostic_verifier(engine)` returns inert diagnostic results from + a caller-supplied byte engine. + +## Security and release gates + +- [x] Exact ordered membership and native plan commitments +- [x] One bounded approval prompt and exact member sequencing +- [x] No partial command exposure on denied or indeterminate plans +- [x] Forgery, copying, pickling, reflection, mutation, substitution, expiry, + cancellation, and duplicate-use failures +- [x] Complete result inspection and inert raw-verifier coverage +- [x] Shared Rust/TypeScript/Python workflow projection +- [x] Strict mypy and Pyright installed-consumer contracts +- [x] Installed-wheel workflow and package-content qualification +- [x] CPython 3.9 and current-version coverage on Linux, macOS, and Windows +- [x] Architecture, compliance, SBOM, provenance, API, docs, and semantic + identity evidence +- [x] Capability metadata promoted to repository-local Full Workflow only + after the preceding evidence exists + +## Claim boundary + +Milestone D supports the repository-local pre-review Full Workflow label. It +does not claim independent review, production readiness, stable-v1 +compatibility, production custody adapters, provider-effect atomicity, or +publication authorization. diff --git a/docs/plans/PYTHON_MCP_VERTICAL_MILESTONE_C.md b/docs/plans/PYTHON_MCP_VERTICAL_MILESTONE_C.md new file mode 100644 index 00000000..8ae24204 --- /dev/null +++ b/docs/plans/PYTHON_MCP_VERTICAL_MILESTONE_C.md @@ -0,0 +1,95 @@ +# Python MCP Vertical — Milestone C + +Status: complete + +## Outcome + +The installed Python SDK can attach an MCP agent, construct and approve an exact tool call, authorize it through the embedded Rust verifier, and execute it through a profile-bound gateway. Python never assembles proof CBOR, request-bound verifier context, protocol identifiers, permission mappings, or executable authorization capabilities. + +## UX + +The normal API has one linear path: + +```python +profile = mcp.profile(service="reports") +agent = await client.attach_agent( + name="reports-agent", + profile=profile, + authority=root_grant, + approval=approval, +) +result = await agent.authorize( + profile.call("update_demo_record", {"value": "reviewed"}) +) + +if result.kind == "authorized": + value = await profile.gateway(execute).execute(result.command) +``` + +Denied and indeterminate results do not contain a command. Normal results expose stable codes, bounded metrics, safe explanations, and an approval summary. Canonical bytes and raw verification artifacts remain in the advanced inspection API. + +## Architecture + +```text +untrusted Python Mapping + | + v +native MCP parser + profile canonicalizer + | + v +Rust action-envelope authoring --> external approval + signer callbacks + | | + +--------------- signed action --------+ + | + v +Rust proof + request-context assembly + | + v +embedded Rust three-way verifier + | + +-- denied / indeterminate --> data only + | + +-- authorized --> native-sealed, one-use McpCommand + | + v + service-bound MCP gateway +``` + +`auths-author` owns the deterministic action envelope and bounded proof/context assembly used by both the WebAssembly and Python bindings. `auths-profile-mcp` remains the sole owner of MCP canonicalization, permission derivation, review display, and decoding from `VerifiedAction`. The Python layer coordinates application callbacks and converts native verdicts into typed results. + +## APIs + +- `mcp.profile(service=...) -> McpProfile` +- `McpProfile.call(name, arguments) -> McpAction` +- `AttachedAgent.authorize(action, request=...) -> McpAuthorizationResult` +- `McpProfile.gateway(executor) -> McpGateway[T]` +- `McpGateway.execute(command, idempotency_key=...) -> tuple[T, McpReceipt]` + +`AuthorizationRequest` supplies a cryptographically random challenge and current evaluation time by default. Explicit values exist for deterministic replay and differential testing. + +## Security properties + +- `McpCommand` has no public constructor and cannot be copied, pickled, subclassed, or restored from state. +- Only an authorized native verifier result can be decoded into `McpCommand`. +- A command is bound to one MCP service and consumed before its executor runs. +- A wrong gateway performs no effect and does not consume the command. +- Denied and indeterminate branches have no command field. +- Provider and gateway failures cross the normal API as stable, non-sensitive errors. +- Public evidence collections and proof material are bounded before verification. + +## Milestone checklist + +- [x] MCP profile facade and exact action construction +- [x] Native proof and trusted-context assembly +- [x] Local three-way authorization +- [x] Native profile-command decoding +- [x] Closed gateway accepting only native-sealed commands +- [x] Safe explanations and normal/advanced separation +- [x] Installed-wheel authorization and execution test +- [x] Denied path proves zero gateway effect +- [x] Command provenance, one-use, profile-binding, and serialization tests +- [x] Static result narrowing fixture + +## Deferred to Milestone D + +Multi-action plan ergonomics, complete inspection coverage, the full cross-language workflow fixture suite, release-wheel operating-system coverage, and final packaging qualification remain outside this milestone. diff --git a/docs/plans/PYTHON_SAFE_NATIVE_WAIST_PLAN.md b/docs/plans/PYTHON_SAFE_NATIVE_WAIST_PLAN.md new file mode 100644 index 00000000..11fc2893 --- /dev/null +++ b/docs/plans/PYTHON_SAFE_NATIVE_WAIST_PLAN.md @@ -0,0 +1,138 @@ +# Python safe native waist + +**Status:** Milestone A implementation in progress + +**Baseline:** `origin/main` at `670c801` + +**Scope:** AP35-PR1 through AP35-PR3 only + +## UX + +The normal verifier remains small and exhaustive: + +```python +result = auths.verify(proof, action, trusted_context) +match result: + case Authorized(): + record_inert_evidence(result) + case Denied() | Indeterminate(): + do_not_execute() +``` + +The public verifier projection is inert and carries no command. Internally, +`VerifiedAction` remains a native Rust-owned object with no Python constructor, +state dictionary, subclass path, copy path, pickle reduction, buffer view, or +bytes-to-handle promotion API. Bounded projections live in `auths.inspection`. + +Typed authoring is split by purpose across `auths.authority`, `auths.trust`, +`auths.lifecycle`, and the integrated workflow. Their values remain +native-owned. Python coordinates them; Rust parses identifiers, constructs +protocol objects, applies attenuation, canonicalizes profiles, creates signing +preimages, and compiles trust. + +## Architecture + +```text +Python result summaries and typed workflow coordination + | + | no protocol constructors or CBOR decoder + v + opaque PyO3 handles + native ABI version 1 + | | | + v v v + auths-author auths-sdk/profile auths-verifier + plan + sign trust + actions decision + seal + \______________|_________________/ + | + v + canonical auths-model objects +``` + +The portable decision record and sealed authorized action come from the same +Rust verifier execution. A denial, indeterminate result, decode failure, or +configuration mismatch carries no native authorization handle. + +## APIs + +The native ABI is versioned independently from the portable result ABI. ABI +version 1 freezes these operation families: + +- principal parsing; +- root grant construction and child-grant planning; +- principal and grant status construction; +- authorization-plan construction; +- MCP profile action canonicalization; +- trusted-context template compilation and request binding; +- exact grant, action, principal-status, and grant-status signing requests; +- signature completion into native signed objects; and +- advanced canonical-byte inspection without capability promotion. + +Normal Python APIs use closed result unions and native types. Functions reject +unknown variants, invalid lengths, duplicate set members, non-canonical input, +and unsupported identifiers at the native boundary. + +## Threat model + +The attacker is arbitrary Python code in the same interpreter. It may import +private modules, inspect module globals, call constructors directly, mutate +objects, subclass classes, invoke reflection, copy or deep-copy values, pickle +or reduce objects, retain aliases, and supply malformed or oversized input. + +Security invariants: + +1. Only the authorized Rust verifier branch creates `VerifiedAction`. +2. Decision data, canonical bytes, digests, and summaries are not capabilities. +3. No Python token, sentinel, module global, constructor argument, or byte + sequence promotes data into `VerifiedAction`. +4. Denied and indeterminate results contain no effect-capable object. +5. Signing requests expose exact preimages but never private key material or a + general-purpose signing primitive. +6. Child grants are constructed only after native attenuation succeeds. +7. Trusted context and profile meaning are compiled by their Rust owners. + +The boundary does not defend against native-memory corruption, a malicious or +replaced wheel, compromised Rust dependencies, a compromised interpreter +process, or an executor that ignores the required native capability type. +Those are supply-chain, process-isolation, and gateway-integrity concerns. + +## Supported runtimes + +- CPython 3.9 and newer; +- abi3 wheels with the `abi3-py39` floor; +- Linux, macOS, and Windows wheel families already governed by release CI; +- synchronous, deterministic, offline native operations in Milestone A. + +PyPy, free-threaded CPython, WebAssembly Python runtimes, source-only consumer +builds, mobile Python runtimes, and alternative interpreters are not claimed. + +## Exact exclusions + +This milestone does not add signer or approval providers, async lifecycle, +`AuthsClient`, attach-agent orchestration, complete delegation workflow, +proof-bundle assembly, a sealed profile command or gateway, receipts, hosted +services, private-key custody, production-readiness claims, stable-v1 claims, +or independent-review claims. Those remain AP35-PR4 and later. + +The package remains labeled **Verifier Binding** until the later Full Workflow +exit gate passes. Milestone A supplies the safe dependency beneath that claim; +it does not promote the product tier by itself. + +## Task list + +- [x] Freeze the Milestone A API, ownership model, threat model, runtimes, and exclusions. +- [x] Return portable decision data and sealed authority from one Rust verifier execution. +- [x] Replace the Python sentinel wrapper with a non-constructible native `VerifiedAction`. +- [x] Move portable-result decoding out of Python and into the Rust binding. +- [x] Bind typed principal, grant, attenuation, status, plan, profile, trust, and signing operations. +- [x] Add native ABI version 1 and a committed machine-readable manifest. +- [x] Add direct-construction, subclass, reflection, copy, deepcopy, pickle, reduce, alias, and bytes-promotion attacks. +- [x] Add Rust/Python/TypeScript differential projections for shared fixtures. +- [x] Add type stubs and built-wheel smoke evidence. +- [x] Refresh architecture, compliance, API, and semantic-freeze inventories. + +## Exit + +Milestone A exits when Python can call every operation needed by later workflow +facades without implementing Auths meaning in Python, all shared fixtures agree +with Rust and TypeScript, and arbitrary Python code cannot mint any object that +a protected Auths effect boundary accepts as authorization. diff --git a/docs/plans/SDK_PRODUCT_SURFACE_AND_PYTHON_PARITY.md b/docs/plans/SDK_PRODUCT_SURFACE_AND_PYTHON_PARITY.md index d2679498..f06d8e01 100644 --- a/docs/plans/SDK_PRODUCT_SURFACE_AND_PYTHON_PARITY.md +++ b/docs/plans/SDK_PRODUCT_SURFACE_AND_PYTHON_PARITY.md @@ -1,410 +1,191 @@ -# Auths SDK product surface and Python Full Workflow parity +# Auths SDK product surface and Python parity -**Status:** EPM product-surface assessment and delivery recommendation -**Snapshot:** `main` at `c47af745` on 2026-08-10 -**Decision:** Python is a target **Full Workflow SDK**, not a verifier-only binding -**Primary references:** [AP-SPEC-027](../specs/0027-product-grade-typescript-sdk.md), [AP-SPEC-035](../specs/0035-python-full-workflow-sdk.md), [AP-SPEC-036](../specs/0036_sdk_ergonomics.md), [AP-SPEC-037](../specs/0037_sdk_ergonomics_2.md), [issue 72](https://github.com/auths-dev/auths-proof/issues/72), and [issue 73](https://github.com/auths-dev/auths-proof/issues/73) +**Status:** Rust, TypeScript, and Python repository surfaces mapped after the +Python elite implementation +**Snapshot:** `main` at `61bedef` plus `codex/python-elite-sdk` on 2026-08-11 +**Release posture:** Prelaunch; implementation completion does not authorize +publication, stable-V1, production, certification, or independent-review claims +**Semantic owner:** Rust +**Language-product owners:** TypeScript and Python -**Execution boundary:** The current implementation branch and pull request are -TypeScript-only. This document maps Python as the next product program; it does -not place Python implementation in the TypeScript branch. Python work begins -only after the TypeScript pull request is complete, on its own branch and pull -request. +## 1. Executive assessment -## 1. Executive summary +Auths now has three coherent but deliberately different products: -Auths has three different language-product states today: - -| Surface | Functional product state | Release-claim state | EPM assessment | +| Surface | Product role | Repository capability | Remaining gate | | --- | --- | --- | --- | -| Rust core and SDK ecosystem | Semantic reference and complete building blocks | Release candidate; exact claims remain gate-controlled | The source of truth and capability ceiling | -| TypeScript SDK | Repository-local Full Workflow implementation exists | Still labeled Verifier Binding in capability metadata; promotion and publication remain blocked | Functionally close to the intended V1 SDK; remaining work is mainly release reconciliation, evidence, and breadth | -| Python SDK | Deterministic three-input verifier only | Correctly limited to verifier behavior | Far from Full Workflow; requires a native-boundary and workflow product build, not a wrapper-only enhancement | - -The most important planning conclusion is: - -> Python should reuse the Rust semantic owners and reproduce the TypeScript product contract idiomatically. It should not reproduce TypeScript implementation details or implement Auths semantics in Python. +| Rust | Semantic reference and capability ceiling | Complete protocol, identity, authority, profile, lifecycle, runtime, receipt, and release building blocks | Exact release-candidate review and publication authorization | +| TypeScript | Browser, Node.js, and edge product | Elite Full Workflow implementation with layered identity, verification, workflow, profiles, runtime, adapters, and packed consumers | Exact release CI, independent review, and promotion | +| Python | Service, automation, data, and agent product | Elite Full Workflow implementation with a safe native waist, layered identity, verification, workflow, profiles, runtime, adapters, and abi3 wheels | Exact release CI, independent review, and promotion | -The desired Python experience is: +The SDKs target customer-journey parity, not symbol-for-symbol sameness: -```text -create/load principal - -> attach agent to signed root authority - -> delegate strictly narrower authority - -> construct an exact profile-owned action - -> approve and sign through external providers - -> assemble proof and trusted context in Rust - -> verify locally - -> authorized | denied | indeterminate - -> hand a native-sealed profile command to a closed gateway -``` +> Rust owns shared meaning. TypeScript and Python expose that meaning in the +> idioms of their ecosystems, without reimplementing canonicalization, +> attenuation, verification, lifecycle, profile commands, or runtime state. -Today Python implements only the `verify locally` and result-projection portion of that path. +## 2. Product tiers -## 2. Product vocabulary - -This assessment uses the cross-language tiers defined by AP-SPEC-027 and issue 72. - -| Tier | Customer can do | Customer must not have to do | +| Tier | Customer can do | Customer never has to do | | --- | --- | --- | -| Verifier Binding | Submit proof, canonical action, and trusted-context bytes; receive one of three verdicts | Reimplement verification semantics | -| Authoring SDK | Create principals, grants, delegations, actions, status objects, signing requests, and trusted contexts | Hand-author protocol CBOR, signing preimages, or attenuation rules | -| Full Workflow SDK | Attach, delegate, authorize, explain, and pass a sealed profile command to a closed gateway | Assemble protocol objects or create effect-capable commands from unverified data | - -“Full Workflow” is a product capability claim, not merely an API-shape claim. A language does not reach the tier until its normal installed-package path crosses the supported Rust semantics and only successful native verification can release a gateway-accepted command. - -## 3. How to read the Rust comparison - -Rust is not one monolithic SDK with every workflow behind one client class. Its product surface is deliberately split across small semantic crates, the integrated `auths-sdk` facade, profile packages, and optional runtime packages. - -Therefore, “compared with Rust” means compared with the supported Rust capability system—not that TypeScript and Python need one-for-one copies of every Rust type. - -This distinction matters because TypeScript is already more cohesive at the application-workflow layer: it has the `loadAuths -> attachAgent -> delegate -> authorize` facade that the Rust `auths-sdk` crate itself does not expose as one equivalent object graph. Rust owns the semantics and primitives; TypeScript currently owns the most polished integrated developer journey. +| Verifier Binding | Submit proof, canonical action, and trusted-context bytes and receive a three-valued decision | Reimplement verification | +| Authoring SDK | Create identity, grants, delegation, status, trust, profiles, plans, and exact signing requests | Hand-author protocol CBOR, commitments, or signing preimages | +| Full Workflow SDK | Attach, delegate, review, approve, authorize, execute a native-sealed command, reconcile, and inspect receipts | Assemble proof objects or mint effect-capable commands | -## 4. Cross-language capability map +Rust, TypeScript, and Python all reach the Full Workflow capability ceiling in +the repository. Capability promotion remains blocked until exact artifact and +external-review gates pass. -Legend: +## 3. Cross-language customer surface -- **Complete:** supported by the current public product surface. -- **Partial:** meaningful support exists, but not the full reference capability. -- **Missing:** no supported public path exists. -- **Unsafe for effects:** data may be useful for inspection, but it cannot safely authorize a gateway effect. - -| Product capability | Rust core/SDK ecosystem | TypeScript SDK | Python SDK | Importance for Python Full Workflow | -| --- | --- | --- | --- | --- | -| Deterministic local verification | Complete | Complete | Complete | Already present | -| Three distinct verdicts | Complete | Complete | Complete | Already present | -| Stable stages, codes, commitments, and metrics | Complete | Complete | Complete | Already present | -| Neutral identity representation and validation | Complete | Partial: packaged raw-key identity path | Missing | Medium for the first authority workflow; high for full layered adoption | -| Signed-message authentication independent of authority | Complete | Partial: Ed25519 raw-key adapter | Missing | Medium; not required to ship the first MCP authority vertical | -| Provider-neutral principal/signer boundary | Complete | Complete | Missing | **Blocker** | -| Transaction-bound signing requests | Complete | Complete | Missing | **Blocker** | -| Root authority preparation/loading | Complete building blocks | Complete for signed sources and self-contained raw-key bootstrap | Missing | **Blocker** | -| Trusted-context construction/loading | Complete | Complete loading and raw-key bootstrap; narrower than Rust's generic composition | Missing | **Blocker** | -| Attach an agent to exact signed authority | Building blocks; no equivalent single high-level facade | Complete | Missing | **Blocker** | -| Strictly non-widening child delegation | Complete | Complete | Missing | **Blocker** | -| Authority diff and over-granting warnings | Complete | Complete | Missing | **Blocker** for safe delegation UX | -| Approval-policy commitment and provider orchestration | Semantic commitments and composition primitives | Complete | Missing | **Blocker** for the promised workflow | -| Profile-owned action construction | Complete, broad profile set | Complete for MCP and application-defined profiles | Missing | **Blocker** | -| Proof and request-context assembly | Complete | Complete through packaged Rust/WASM | Missing | **Blocker** | -| Profile-owned command decoding | Complete | Complete | Missing | **Blocker** | -| Non-forgeable gateway command | Complete native type | Complete package-owned sealed path | Unsafe for effects | **Blocker and first security dependency** | -| Ordered multi-action plan authorization | Complete primitives and profile commitments | Complete ordered profile plans | Missing | High for TypeScript feature parity; can follow the first single-action vertical | -| General all-of, any-of, and threshold proof plans | Complete | Not exposed as the same general public authoring surface | Missing | Medium; not required for first Full Workflow vertical | -| Principal/grant status authoring and lifecycle inputs | Complete | Native ABI support exists, but public workflow coverage is narrower | Missing | High before production lifecycle claims; not the first vertical blocker | -| Deterministic cleanup and ephemeral-signer lifecycle | Rust ownership/RAII | Complete explicit async disposal | Missing | **Blocker** for async provider safety | -| Advanced raw verification and bounded inspection | Complete | Complete under `advanced` | Partial: raw verification plus decoded result | High, but should be delivered after the safe normal path is established | -| Custom profile development and conformance | Complete | Complete application profile kit and testkit | Missing | High for SDK extensibility; not required for first MCP vertical | -| Receipts, replay, budgets, lifecycle stores, and effect runtimes | Available in optional Rust product packages | Command handoff exists; operational runtime remains external | Missing | Not a base-SDK parity blocker; integrate through explicit ports later | -| Production custody/provider adapters | Rust ports and selected product integrations | Intentionally not bundled in the base SDK | Intentionally absent | Ecosystem work, not a Full Workflow SDK blocker | -| Installed artifact and cross-platform evidence | Rust release machinery exists | Strong packed Node/browser evidence; promotion gates remain | Wheels exist for verifier only | **Blocker** for shipping the Python claim after implementation | - -## 5. Rust core and SDK product surface - -### 5.1 What Rust supports - -Rust is the semantic owner and supports the full mechanism needed by every language SDK. - -#### Identity and authentication - -- Bounded, method- and suite-labelled identity descriptors. -- Distinct decoded, validated, and authenticated identity states. -- Simple raw keys plus composite, rotating, or resolver-shaped verification relationships. -- Neutral identity-method and signature-verifier ports. -- Canonical identity packets and signed application-message preimages. -- Raw-key and Ed25519 reference adapters. -- An explicit, optional validated-identity-to-authority bridge. - -#### Authority authoring - -- Canonical principals, grants, permissions, resources, audiences, validity windows, budgets, status policies, assurance floors, critical extensions, and delegation depth. -- Root and child grant construction through typed Rust objects. -- Child-grant planning that derives issuer and parent linkage and rejects widening before signing. -- Semantic authority diffs and over-granting warnings. -- General proof composition through all-of, any-of, and threshold plans. -- Canonical approval-policy and exact-plan commitments. - -#### Signing and custody - -- Exact, domain-separated signing preimages for grants, actions, principal status, and grant status. -- Transaction-bound external signing requests. -- A provider-neutral custody trait for WebAuthn, workload, KMS, HSM, and PKCS#11 families. -- Response validation that prevents a provider result from being substituted across transactions. -- No requirement that Auths own or receive private keys. - -#### Trusted verification - -- Immutable trusted-context construction with explicit roots, registries, status snapshots, assurance policy, limits, supported methods, suites, profiles, and critical extensions. -- Explicit per-request audience, replay challenge, and evaluation time. -- Raw-key, `did:key`, and `did:keri` reference principal verification in the self-contained verifier. -- Ed25519 and P-256 verification suites in the integrated verifier. -- Deterministic, effect-free verification with authorized, denied, and indeterminate outcomes. -- Stable explanations, codes, stages, configuration commitments, and work metrics. - -#### Profiles and enforcement - -- A neutral `ActionProfile` contract for canonicalization, authority projection, review display, and verified-command decoding. -- MCP plus HTTP, Git, deployment, supply-chain, and edge profile families, with additional domain integrations elsewhere in the product tree. -- A native `VerifiedAction` constructible only by successful verification. -- Profile-decoded commands that can enter a closed executor boundary. -- Optional enforcement, replay, budget, receipt, lifecycle, evidence, and operational packages. - -### 5.2 What Rust does not currently optimize - -Rust does not provide the exact same high-level application facade as TypeScript. A Rust adopter composes `auths-sdk`, `auths-author`, custody, profiles, and optional runtime packages rather than calling a single `attach_agent` client workflow. - -This is primarily an ergonomics difference, not a missing semantic capability. It should not force Python to copy Rust's crate-level composition. Python should follow the proven Full Workflow product journey while delegating each semantic operation to its Rust owner. - -## 6. TypeScript SDK product surface - -### 6.1 What TypeScript supports now - -The repository-local TypeScript implementation supports the normal Full Workflow journey: - -- Package-owned Rust/WASM loading in Node and supported browsers. -- A separate identity-only entry point with decoded, validated, and authenticated states. -- Raw-key identity creation and Ed25519 signed-message authentication. -- Provider-neutral `Signer`, approval, signed-grant, and trusted-context ports. -- Self-contained raw-key authority preparation without application-authored grant or context CBOR. -- `loadAuths`, `attachAgent`, narrower `delegate`, `authorize`, and `authorizePlan` workflows. -- Native Rust-owned attenuation checks, authority diffs, and over-granting warnings. -- Typed MCP actions and commands. -- An application profile kit that preserves profile ownership rather than introducing a generic executor. -- Exact ordered action-plan commitments and bounded plan-once approval reuse. -- Authorized, denied, and indeterminate results with stable native details. -- Package-owned command minting and hostile non-forgeability tests. -- Deterministic disposal and explicit ephemeral-signer lifetimes. -- An advanced raw verifier and bounded decision inspection surface. -- Separate development-only signers, approval fixtures, and profile conformance tools. -- Packed-package Node, browser, example, API-snapshot, and external-consumer tests. - -### 6.2 What TypeScript is missing compared with Rust - -| Gap | Impact | Priority | EPM interpretation | +| Product capability | Rust | TypeScript | Python | | --- | --- | --- | --- | -| Capability metadata still says `verifier-binding` and excludes Full Workflow while the implementation and README describe the workflow | Customers and release automation receive contradictory product claims | **Release blocker** | Reconcile only after exact required CI/review evidence; do not solve by changing copy alone | -| Full generic Rust trust-context and adapter composition is not exposed as an equally broad typed TypeScript builder | Non-raw-key and deployment-specific trust integrations require provider-supplied native context | High for ecosystem breadth; not a first MCP V1 blocker | Add ports/fixtures based on real integrations rather than exposing protocol constructors | -| Identity helper coverage is narrower than the Rust port model | Packaged convenience proves raw-key plus Ed25519, not every key, suite, resolver, or composite method | Medium | Correct architectural choice: Auths owns the port and conformance, not every adapter | -| Built-in profile coverage is much narrower than Rust | MCP and application-defined profiles work; Rust has more maintained domain packages | Medium | Expand only behind validated V1 workflows; profile breadth is not core SDK completeness | -| Public lifecycle/status authoring is narrower than Rust's primitives | Applications can consume trusted inputs but do not get the entire native status-authoring surface as a polished workflow | High before production lifecycle/revocation claims | Follow a specific lifecycle product use case | -| Public plan authoring is an ordered profile plan, not Rust's complete general proof-composition API | Advanced all-of/any-of/threshold compositions are not equally ergonomic | Medium | Not required for the first attach/delegate/authorize promise | -| Receipts, replay, durable budgets, and provider effects are not base-SDK workflows | TypeScript hands off a sealed command but does not own operational execution | Intentional | Keep these behind explicit gateways and runtime integrations | -| No bundled production custody provider | A customer must implement or select a signer adapter | Intentional but commercially important | Auths should supply conformance and a small number of reference integrations, not own every adapter | -| Publication and independent-review gates remain open | The implementation cannot yet be promoted as reviewed or generally released Full Workflow | **Release blocker** | Functional readiness and claim readiness must remain separate | - -### 6.3 TypeScript assessment - -TypeScript is not materially missing the core Full Workflow mechanics. It is the current ergonomic reference for Python. - -The most important TypeScript follow-up is to reconcile issue state, `sdk-capability.json`, documentation, exact-platform CI evidence, and release claims. The next most important product work is validating whether real users need broader trust-context, lifecycle, or profile support—not automatically matching every Rust crate. - -## 7. Python SDK product surface - -### 7.1 What Python supports now - -The installed `auths` package currently provides: - -- A Maturin/PyO3 native extension using the stable `abi3-py39` floor. -- One synchronous, deterministic, effect-free operation: - `verify(proof_cbor, canonical_action_cbor, trusted_context_cbor)`. -- The canonical Rust self-contained V1 verifier rather than a Python rewrite. -- Authorized, denied, and indeterminate result dataclasses. -- Stable result code, verification stage, safe explanation, work metrics, required configuration, local configuration, and canonical result CBOR. -- Bounded canonical result decoding with version, shape, depth, and trailing-data rejection. -- Inline type information and `py.typed`. -- Native wheels as the intended distribution shape, so consumers do not need Rust or C to run verification. - -This is a useful Verifier Binding. It is not an Authoring SDK or a Full Workflow SDK. - -### 7.2 Critical current security limitation - -Python's `VerifiedAction` is protected by an accessible module-level sentinel: - -```python -_AUTHORIZED_TOKEN = object() -``` - -Application code can import or inspect that sentinel and construct a `VerifiedAction` around arbitrary bytes. The current object is therefore useful as an authorized-result data wrapper, but it is **not a non-forgeable capability** and must not be accepted by a protected gateway. - -This is the first implementation dependency because building `attach_agent`, delegation, and profile facades on top of the current object would create a workflow that looks complete but has a bypass at its most important boundary. - -### 7.3 What Python is missing compared with Rust - -| Missing Python capability | Customer consequence | Importance | -| --- | --- | --- | -| Native-only authorized action and profile-command handles | Python code can forge the object that a gateway might trust | **P0 — security and Full Workflow blocker** | -| Native authoring ABI for principals, grants, status, trusted context, and exact signing requests | Applications would have to recreate protocol semantics or cannot author at all | **P0 — Full Workflow blocker** | -| Provider-neutral async signer protocol | No safe way to create/load an agent or sign grants/actions without exporting keys | **P0 — Full Workflow blocker** | -| Approval provider and committed policy execution | No safe supervised, headless, risk-based, every-action, or plan-once workflow | **P0 — Full Workflow blocker** | -| Trusted-authority and trusted-context source APIs | No supported normal path for roots, registries, status, assurance, and limits | **P0 — Full Workflow blocker** | -| `AuthsClient` and `attach_agent` | Python cannot begin the normal product journey | **P0 — Full Workflow blocker** | -| Root grant preparation/loading and effective-authority summary | Python cannot attach exact authority without raw CBOR | **P0 — Full Workflow blocker** | -| Narrower child delegation with diffs and warnings | Python cannot express the flagship Auths value proposition | **P0 — Full Workflow blocker** | -| Profile-owned MCP action construction | Python cannot bind authority to an exact application action safely | **P0 — first vertical blocker** | -| Native proof/context assembly | Python callers must supply the three protocol byte strings themselves | **P0 — Full Workflow blocker** | -| `authorize` returning a sealed MCP command | Python cannot safely cross from verification to an effect gateway | **P0 — Full Workflow blocker** | -| Async cancellation and deterministic signer/agent cleanup | Provider calls can leave partial or reusable security state | **P0 — workflow safety blocker** | -| Ordered multi-action plans and plan-once approval | Python cannot match the TypeScript reference workflow for compound actions | **P1 — feature-parity blocker, after single-action vertical** | -| Advanced API separation and bounded inspection | Raw bytes and effect-capable objects cannot be kept in visibly different product surfaces | **P1** | -| Mypy and Pyright misuse fixtures | Python users cannot rely on types to distinguish verdicts and profile commands | **P1** | -| Identity-only and authenticated-message surface | Python cannot participate in the lower, independently adoptable identity layers | **P2 for first authority vertical; P1 for complete layered parity** | -| Application profile kit and conformance tooling | Customers cannot add their own closed action vocabulary without bespoke native work | **P1 for extensibility** | -| Clean-wheel Full Workflow tests across CPython, macOS, Linux, and Windows | A source-tree success cannot become a supportable SDK claim | **P0 release blocker after implementation** | -| Cross-language Full Workflow fixtures | Drift can exist above the raw verifier even when verdict fixtures agree | **P0 release blocker** | - -### 7.4 Python assessment - -Python currently covers roughly the last third of the internal authorization pipeline but only the first product tier. It can evaluate already-assembled inputs; it cannot safely create those inputs or release a protected effect. - -The missing work is substantial but bounded. Most semantic machinery already exists in Rust, and TypeScript has already tested the product vocabulary. The Python program is primarily: - -1. exposing the existing Rust operations through a safe native ABI; -2. designing an idiomatic asynchronous Python workflow around them; -3. enforcing native capability ownership at the gateway boundary; and -4. producing wheel, typing, adversarial, and cross-language evidence. - -It should not require new protocol semantics. - -## 8. Recommended Python delivery sequence - -The existing AP-SPEC-035 nine-unit plan is directionally correct. From an EPM perspective, it should be managed as four customer-visible milestones. - -### Milestone A — Establish a safe native waist - -Includes AP35-PR1 through AP35-PR3. - -Deliver: - -- Freeze the Python API, threat model, supported runtimes, and exact exclusions. -- Replace the sentinel-protected `VerifiedAction` with an opaque native type or native-owned handle. -- Bind the Rust authoring, trusted-context, status, plan, profile, and signing-request operations required by the workflow. -- Establish ABI versioning and Python/Rust/TypeScript differential fixtures. - -Exit outcome: - -> Python can call every required semantic operation without implementing Auths meaning in Python, and arbitrary Python code cannot mint an effect-capable authorization object. - -This milestone is the hard dependency for every later workflow API. - -### Milestone B — Make agent attachment and delegation real - -Includes AP35-PR4 through AP35-PR6. - -Deliver: - -- Async signer and approval `Protocol` interfaces. -- Exact request/response binding, typed provider failures, cancellation, and cleanup. -- `AuthsClient`, trusted-authority loading, `attach_agent`, and root-authority summaries. -- `delegate` with Rust-owned attenuation, semantic diff, warnings, approval, and signing. - -Exit outcome: - -> A Python application can attach an agent and delegate narrower authority without handling protocol bytes or private keys. - -### Milestone C — Close the first end-to-end MCP vertical - -Includes AP35-PR7 and the minimum normal-path portion of AP35-PR8. - -Deliver: - -- An MCP profile facade with exact action construction. -- Native proof and trusted-context assembly. -- Local three-valued authorization. -- Native profile-command decoding. -- A closed MCP gateway contract that accepts only the native-sealed command. -- Safe explanations and normal/advanced API separation. - -Exit outcome: - -> From an installed development wheel, Python can attach, delegate, authorize one MCP tool call, reject an unauthorized call with zero gateway effect, and execute only the successful native-sealed command. - -At this point Python has the minimum functional Full Workflow vertical, but it should not yet receive the release claim. - -### Milestone D — Reach feature and release parity - -Includes ordered plans, the remainder of AP35-PR8, and AP35-PR9. - -Deliver: - -- Ordered multi-action plans and exact plan-once approval. -- Complete result inspection and advanced raw verifier support. -- Mypy and Pyright consumer contracts. -- Application profile-kit and conformance path, or an explicit documented deferral. -- Shared Rust/TypeScript/Python workflow fixtures. -- Adversarial native-handle, provider, cancellation, widening, and mutation suites. -- Isolated installed-wheel workflows on the supported CPython and OS matrix. -- Package-content, architecture, compliance, SBOM, provenance, API, and documentation gates. -- Capability metadata promotion only after the evidence passes. - -Exit outcome: - -> Python can be truthfully labeled and shipped as a Full Workflow SDK with the same semantic contract as TypeScript. - -## 9. Recommended priority and dependency order - -| Order | Workstream | Why now | -| ---: | --- | --- | -| 1 | Reconcile AP-SPEC-035's entry-gate policy | The current specification says implementation is blocked on independent review. If repository-local pre-review implementation is now authorized, record the same bounded claim model used for TypeScript before coding begins. | -| 2 | Fix issue 73 with a native non-forgeable design | Every effect-capable Python workflow depends on this boundary. | -| 3 | Expose the Rust authoring and trusted-input ABI | This removes the temptation to implement protocol meaning in Python. | -| 4 | Build async provider protocols and lifecycle | Attachment and delegation need safe external signing and approval. | -| 5 | Implement attach and root authority | First recognizable SDK activation step. | -| 6 | Implement narrower delegation | The central Auths product value. | -| 7 | Implement MCP authorize and sealed gateway command | First complete customer-visible vertical. | -| 8 | Add ordered plans, advanced inspection, and profile extensibility | Brings Python toward TypeScript product parity. | -| 9 | Close wheel, type, platform, and cross-language evidence | Converts repository code into a supportable SDK claim. | -| 10 | Promote capability metadata after the gate | Claims must follow evidence, never precede it. | - -## 10. Full Workflow definition of done for Python - -Python is complete only when an external consumer, using an installed wheel and no Auths source checkout, can: - -1. Configure an external signer and approval provider without exporting a private key. -2. Load or prepare exact trusted authority without authoring protocol CBOR. -3. Attach an agent to a signed root grant. -4. Delegate a child that Rust proves is no wider in every authority dimension. -5. Construct an exact MCP action through the profile API. -6. Receive authorized, denied, or indeterminate without collapsing outcomes. -7. Pass only the authorized native profile command to a matching closed gateway. -8. Demonstrate that forged, copied, pickled, reflected, mutated, substituted, denied, and indeterminate objects cannot reach the gateway. -9. Dispose ephemeral signers and partial workflow state on success, failure, timeout, and cancellation. -10. Produce the same semantic projection as Rust and TypeScript for shared workflow fixtures. -11. Run from the exact supported wheel matrix without Rust installed. -12. Pass architecture, API, compliance, semantic-freeze, SBOM, provenance, and authoritative CI gates on the same revision. - -## 11. Explicit non-goals - -Python Full Workflow parity does not require Auths to: - -- rewrite Rust canonicalization, attenuation, verification, or profile semantics in Python; -- bundle private keys or a development signer into the production import root; -- own every KMS, HSM, wallet, key, identity, or transport adapter; -- expose every Rust profile before the first MCP vertical works; -- make approval or capabilities mandatory for identity-only users; -- execute arbitrary provider operations automatically after authorization; -- build a generic operation-tag executor; -- require an Auths-hosted service; -- claim exactly-once external effects; or -- claim stable V1, production readiness, or independent review merely because the workflow compiles. - -## 12. EPM recommendation - -Treat TypeScript as the **ergonomic reference**, Rust as the **semantic reference**, and Python as the next **full product implementation**. - -Do not plan Python as a parity checklist against every Rust crate. Plan it around one complete customer journey, then expand breadth: +| Deterministic local verification | Complete | Complete | Complete | +| Authorized, denied, indeterminate | Complete | Complete | Complete | +| Standalone identity and exact-message authentication | Complete | Complete | Complete | +| Credential-shape-agnostic methods and suites | Complete ports and reference adapters | Versioned ports and reference adapters | Versioned async ports, raw-key/Ed25519 and resolver-backed reference paths | +| Explicit identity-to-authority bridge | Complete | Complete | Complete; preserves method, relationship, suite, purpose, provenance, and assurance | +| Typed trust, assurance, evidence, and status | Complete | Complete | Complete | +| Root authority and agent attachment | Complete building blocks | Complete integrated workflow | Complete integrated workflow | +| Strictly narrower delegation and semantic diff | Complete | Complete | Complete | +| Approval policies and provider orchestration | Complete primitives | Complete | Complete; none, grant-only, every-action, risk, threshold, custom, and plan-once | +| Exact signing and custody ports | Complete | Complete | Complete async ports and transaction binding | +| Maintained MCP profile | Complete | Complete | Complete | +| Maintained HTTP profile | Complete | Complete | Complete | +| Application profile kit | Complete profile contract | Complete | Complete; Python owns typed payload conversion while Rust brands commands | +| Ordered profile plans | Complete primitives | Complete | Complete | +| All-of, any-of, and threshold proof plans | Complete | Complete | Complete Rust-owned builder | +| Native-only effect command | Complete | Complete package-owned path | Complete non-constructible, non-copyable, non-pickleable, profile-bound, one-use path | +| Replay, budget, lifecycle, and reconciliation state | Complete | Complete ports and native state | Complete ports and native state | +| Exact execution receipts | Complete canonical receipts | Complete profile receipts | Complete profile receipts bound to action, proof authority, trust context, native state, outcome, and plan membership | +| Batch verification | Complete | Complete | Complete, bounded and GIL-releasing | +| Errors, inspection, telemetry, support bundle | Complete schemas | Complete | Complete and redacted | +| Replaceable adapters and conformance | Complete ports | Complete | Complete contracts, testkit, recipes, and separate SQLite reference package | +| Isolated release artifact consumers | Complete release system | Packed package matrix | abi3 wheel matrix with Rust removed | + +## 4. Rust capability surface + +Rust remains the only place where cross-language security meaning is defined. +Its principal surfaces are: + +- credential-shape-agnostic identity descriptors, method relationships, + signature suites, signed-message preimages, and explicit authority bridges; +- canonical principals, grants, permissions, resources, audiences, validity, + budgets, status, assurance, critical extensions, and delegation depth; +- root and child authoring, non-widening attenuation, semantic diffs, signing + requests, transaction commitments, and proof composition; +- trusted contexts, evidence and status snapshots, freshness and assurance, + deterministic verification, explanations, metrics, and commitments; +- maintained profiles, review displays, verified-command decoding, ordered + plan commitments, and closed execution boundaries; +- replay, budget, receipt, retry, reconciliation, and outcome-unknown state + machines; and +- exact release subjects, SBOMs, provenance, semantic freeze, differential + fixtures, and formal qualification. + +Rust intentionally does not force every language to reproduce its crate graph. +The language SDKs compose its operations around complete application journeys. + +## 5. TypeScript product surface + +TypeScript is the ergonomic reference for browser, Node.js, and edge adoption. +Its elite specification provides: + +- independent identity, authentication, verification, inspection, and + diagnostics entry points; +- typed authoring, trust, lifecycle, attach, delegation, approval, and plan + workflows over the packaged Rust/WASM core; +- MCP, maintained domain profiles, and an application profile kit; +- package-owned sealed commands, closed gateways, replay/budget state, + receipts, observability, testkit, and adapter contracts; and +- packed direct-ESM, browser, worker, runtime, API, content, and hostile-boundary + qualification. + +TypeScript does not own every identity method, cryptographic suite, KMS, +resolver, transport, store, framework, or telemetry implementation. It owns +the port, conformance boundary, and a small reference set. + +## 6. Python product surface + +Python now exposes the same complete product journey through a Python-native +surface: ```text -P0: safe native command - -> native authoring/trust ABI - -> async signer and approval ports - -> attach - -> delegate narrower - -> authorize one MCP action - -> closed gateway - -P1: ordered plans + inspection + custom profiles + lifecycle ergonomics - -P2: broader identity, suite, profile, custody, and transport adapters driven by adoption +auths.identity + -> auths.verify / auths.inspection / auths.diagnostics + -> auths.trust + auths.lifecycle + auths.authority + -> AuthsClient.attach_agent -> delegate -> authorize / authorize_plan + -> auths.profiles.mcp | auths.profiles.http | auths.profile_kit + -> native-sealed command -> idempotent gateway -> exact receipt + -> auths.runtime reconciliation and durable adapter ports ``` -The first Python milestone is successful when it proves the security boundary. The program is successful when a normal Python developer can use that boundary without knowing it exists. +Key properties: + +- `auths.identity` imports without authority, approval, profile, lifecycle, or + runtime machinery. Authentication never creates permission. +- Python coordinates callbacks; Rust owns identity descriptor encoding, + commitments, authoring, attenuation, proof assembly, verification, profile + command branding, plan membership, and runtime state. +- Public verification results are inert. Only the integrated package-owned + workflow can turn an authorized native result into a profile command. +- MCP, HTTP, and application profiles expose review before approval and use + distinct action, authority, command, plan, gateway, receipt, and error types. +- Every effectful gateway requires an application idempotency key. Provider + failure or cancellation after entry yields typed outcome-unknown evidence + and completed plan-member receipts for reconciliation. +- Identity methods, suites, resolvers, custody, approval, evidence, clocks, + stores, telemetry, gateways, transports, and frameworks remain replaceable + versioned ports. +- `auths-sqlite` is a separately packaged durable reference. Vendor adapters + are not dependencies of the base wheel. +- The public topology contains no `auths.advanced`, `auths.native`, or + `auths.mcp` compatibility path. + +## 7. Intentional language differences + +| Difference | Product reason | +| --- | --- | +| Rust exposes crates and traits; TypeScript and Python expose cohesive clients and modules | Language users should not reconstruct the Rust composition graph | +| TypeScript uses promises, browser workers, and Web APIs; Python uses protocols, dataclasses, async context managers, strict mypy/Pyright, and controlled GIL release | Native ecosystem fit without semantic drift | +| Python distributes abi3 wheels; TypeScript distributes JavaScript plus an exact WASM subject | Each artifact uses its ecosystem's compiler-free consumption model | +| Python's maintained reference store is SQLite; TypeScript proves browser/server storage substitution | Reference adapters prove the port and do not define meaning | +| Adapter breadth differs | Auths owns conformance and selected examples, not the entire integration ecosystem | + +These are not parity gaps. A gap exists only when a customer journey loses +meaning, safety, evidence, or operability in one supported SDK. + +## 8. Current release gaps + +The remaining gaps are evidence and release authority, not missing Python +workflow mechanics: + +1. Run the exact branch through authoritative CI and the full installed-wheel + matrix on Linux, macOS, and Windows for CPython 3.9–3.14. +2. Produce exact candidate SBOM and signed provenance through the existing + release-control workflow. +3. Complete independent security and external-consumer review against those + exact artifacts. +4. Reconcile capability promotion and publication only after all gates pass. + +No gate should be closed by changing marketing copy or capability metadata +alone. + +## 9. Definition of cross-SDK parity + +Rust, TypeScript, and Python have product parity when an external team can: + +1. exchange and authenticate identity without enabling capabilities; +2. substitute supported adapters without changing Auths meaning; +3. attach exact authority and delegate only narrower authority; +4. review, approve, authorize, and plan across maintained profiles; +5. execute only a native-minted command at a matching closed gateway; +6. reject replay, consume budget, reconcile unknown outcomes, and retain an + exact receipt; +7. inspect decisions through stable, redacted operational evidence; +8. obtain the same semantic result from shared Rust-owned scenarios; and +9. install the exact ecosystem artifact without another language toolchain. + +The repository implements this surface. Stable-V1, production, certification, +independent-review, and publication claims remain blocked until exact release +evidence authorizes them. diff --git a/docs/scratch/07_DECOUPLE_REVIEW_FROM_APPROVAL.md b/docs/scratch/07_DECOUPLE_REVIEW_FROM_APPROVAL.md index 94f08a11..3f618435 100644 --- a/docs/scratch/07_DECOUPLE_REVIEW_FROM_APPROVAL.md +++ b/docs/scratch/07_DECOUPLE_REVIEW_FROM_APPROVAL.md @@ -54,7 +54,7 @@ This separates “what the action means” from “whether a human permits it. 1. Introduce `ReviewDisplay` with the current bounded fields. 2. Add `ActionProfile::review_display`. 3. Adapt approval workflows to consume it. -4. Temporarily retain `approval_display` as a deprecated forwarding method if compatibility requires it. +4. Remove `approval_display` and its aliases in the same prelaunch cutover. 5. Update profile conformance tests to use neutral terminology. 6. Move approval-specific copy and policy out of profile packages where possible. diff --git a/docs/specs/0035-python-full-workflow-sdk.md b/docs/specs/0035-python-full-workflow-sdk.md index f1e8f12f..174ccfcf 100644 --- a/docs/specs/0035-python-full-workflow-sdk.md +++ b/docs/specs/0035-python-full-workflow-sdk.md @@ -1,8 +1,8 @@ # AP-SPEC-035: Python Full Workflow SDK -**Status:** Specified — implementation is blocked on AP-SPEC-032, the -AP-SPEC-033 Phase 9 exit gate, an immutable reviewed SDK baseline, and the -non-forgeable Python command correction in issue 73 +**Status:** Milestones A and B implemented on the immutable RC baseline as a +repository-local pre-review surface; Full Workflow promotion remains blocked +on the later AP-SPEC-035 exit gates **Governs:** The Python Full Workflow SDK addition to Phase 10 in the [Post-Milestone 6 Productization and Release diff --git a/product/integrations/auths-custody/src/lib.rs b/product/integrations/auths-custody/src/lib.rs index 85c761a1..b8dd6a5f 100644 --- a/product/integrations/auths-custody/src/lib.rs +++ b/product/integrations/auths-custody/src/lib.rs @@ -9,9 +9,9 @@ use auths_author::{ExternalSigningRequest, SigningObjectId}; use auths_model::{ - ActionEnvelope, EvidenceObject, GrantStatement, GrantStatusStatement, PrincipalStatusStatement, - SignatureBytes, SignatureDescriptor, SignedAction, SignedGrant, SignedGrantStatus, - SignedPrincipalStatus, + ActionEnvelope, EvidenceObject, GrantStatement, GrantStatusStatement, PrincipalId, + PrincipalStatusStatement, SignatureBytes, SignatureDescriptor, SignedAction, SignedGrant, + SignedGrantStatus, SignedPrincipalStatus, }; use std::fmt; use subtle::ConstantTimeEq as _; @@ -111,6 +111,78 @@ impl CustodySignature { transaction_digest, } } + + /// Consumes validated provider output into its signature and evidence. + #[must_use] + pub fn into_parts(self) -> (SignatureBytes, Vec) { + (self.signature, self.evidence) + } +} + +/// Language-neutral provider response before exact transaction binding. +pub struct ProviderSigningResponse { + request_id: String, + principal: PrincipalId, + descriptor: SignatureDescriptor, + signature: SignatureBytes, + evidence: Vec, + transaction_digest: [u8; 32], +} + +impl ProviderSigningResponse { + /// Constructs one untrusted response returned by an external provider. + #[must_use] + pub fn new( + request_id: String, + principal: PrincipalId, + descriptor: SignatureDescriptor, + signature: SignatureBytes, + evidence: Vec, + transaction_digest: [u8; 32], + ) -> Self { + Self { + request_id, + principal, + descriptor, + signature, + evidence, + transaction_digest, + } + } +} + +/// Binds an untrusted provider response to one exact Auths signing request. +/// +/// # Errors +/// +/// Returns a closed mismatch before the response signature can complete an +/// Auths object. +pub fn validate_provider_response( + request: &ExternalSigningRequest, + expected_principal: &PrincipalId, + response: ProviderSigningResponse, +) -> Result { + if response.request_id != request.request_id() { + return Err(CustodyError::RequestMismatch); + } + if &response.principal != expected_principal { + return Err(CustodyError::PrincipalMismatch); + } + if &response.descriptor != request.descriptor() { + return Err(CustodyError::DescriptorMismatch); + } + if !bool::from( + response + .transaction_digest + .ct_eq(request.transaction_digest().as_bytes()), + ) { + return Err(CustodyError::TransactionMismatch); + } + Ok(CustodySignature::new( + response.signature, + response.evidence, + response.transaction_digest, + )) } /// Effect port implemented by one configured external custody client. @@ -244,6 +316,12 @@ pub enum CustodyError { Rejected, /// Provider signature encoding was invalid. InvalidSignature, + /// Returned output names a different Auths request. + RequestMismatch, + /// Returned output names a different principal. + PrincipalMismatch, + /// Returned output substitutes the signature descriptor. + DescriptorMismatch, /// Returned output was bound to different Auths signing bytes. TransactionMismatch, } @@ -254,6 +332,9 @@ impl fmt::Display for CustodyError { Self::Unavailable => "external custody provider unavailable", Self::Rejected => "external custody provider rejected the request", Self::InvalidSignature => "external custody provider returned an invalid signature", + Self::RequestMismatch => "custody output names a different signing request", + Self::PrincipalMismatch => "custody output names a different principal", + Self::DescriptorMismatch => "custody output substitutes the signature descriptor", Self::TransactionMismatch => "custody output is bound to a different Auths transaction", }) } @@ -347,4 +428,63 @@ mod tests { Err(CustodyError::TransactionMismatch) )); } + + #[test] + fn provider_response_binds_request_principal_descriptor_and_transaction() { + let request = request(); + let principal = auths_model::PrincipalId::parse("raw:test").unwrap(); + let response = ProviderSigningResponse::new( + request.request_id(), + principal.clone(), + request.descriptor().clone(), + SignatureBytes::new(vec![7; 64]).unwrap(), + Vec::new(), + *request.transaction_digest().as_bytes(), + ); + assert!(validate_provider_response(&request, &principal, response).is_ok()); + + let mismatch = ProviderSigningResponse::new( + "action:substituted".to_owned(), + principal.clone(), + request.descriptor().clone(), + SignatureBytes::new(vec![7; 64]).unwrap(), + Vec::new(), + *request.transaction_digest().as_bytes(), + ); + assert!(matches!( + validate_provider_response(&request, &principal, mismatch), + Err(CustodyError::RequestMismatch) + )); + + let other_principal = auths_model::PrincipalId::parse("raw:other").unwrap(); + let mismatch = ProviderSigningResponse::new( + request.request_id(), + other_principal, + request.descriptor().clone(), + SignatureBytes::new(vec![7; 64]).unwrap(), + Vec::new(), + *request.transaction_digest().as_bytes(), + ); + assert!(matches!( + validate_provider_response(&request, &principal, mismatch), + Err(CustodyError::PrincipalMismatch) + )); + + let mismatch = ProviderSigningResponse::new( + request.request_id(), + principal.clone(), + SignatureDescriptor::new( + auths_model::PrincipalMethodId::parse("raw-key-v1").unwrap(), + VerificationMethod::parse("raw:test").unwrap(), + SignatureSuiteId::parse("p256-sha256-v1").unwrap(), + ), + SignatureBytes::new(vec![7; 64]).unwrap(), + Vec::new(), + *request.transaction_digest().as_bytes(), + ); + assert!(matches!( + validate_provider_response(&request, &principal, mismatch), + Err(CustodyError::DescriptorMismatch) + )); + } } diff --git a/product/integrations/auths-enforcement/src/lib.rs b/product/integrations/auths-enforcement/src/lib.rs index b45ca198..004db6fa 100644 --- a/product/integrations/auths-enforcement/src/lib.rs +++ b/product/integrations/auths-enforcement/src/lib.rs @@ -102,13 +102,6 @@ impl PreparedAction { pub const fn review_display(&self) -> &ReviewDisplay { &self.display } - - /// Compatibility accessor for callers migrating to neutral review vocabulary. - #[must_use] - #[deprecated(note = "use review_display; review data is not evidence of approval")] - pub const fn approval_display(&self) -> &ReviewDisplay { - self.review_display() - } } /// Protocol outcome at a service enforcement boundary. diff --git a/product/profiles/auths-profile-api/src/lib.rs b/product/profiles/auths-profile-api/src/lib.rs index 4a3d901e..96e71207 100644 --- a/product/profiles/auths-profile-api/src/lib.rs +++ b/product/profiles/auths-profile-api/src/lib.rs @@ -48,10 +48,6 @@ impl ReviewDisplay { } } -/// Compatibility name for the pre-neutralization profile display. -#[deprecated(note = "use ReviewDisplay; review data is not evidence of approval")] -pub type ApprovalDisplay = ReviewDisplay; - /// Exact application profile implemented on both sides of verification. pub trait ActionProfile { /// Command type safe for a profile executor. @@ -76,19 +72,6 @@ pub trait ActionProfile { action: &CanonicalAction, ) -> Result; - /// Compatibility forwarding method for callers migrating to neutral review vocabulary. - /// - /// # Errors - /// - /// Forwards the exact profile error from [`Self::review_display`]. - #[deprecated(note = "use review_display; review data is not evidence of approval")] - fn approval_display( - &self, - action: &CanonicalAction, - ) -> Result { - self.review_display(action) - } - /// Decodes only sealed verified data into an executable domain command. /// /// # Errors diff --git a/release/semantic-freeze.json b/release/semantic-freeze.json index 0b539adc..9328a2f1 100644 --- a/release/semantic-freeze.json +++ b/release/semantic-freeze.json @@ -1,6 +1,6 @@ { "schema": "auths.semantic-freeze/1", - "freezeVersion": 50, + "freezeVersion": 56, "publicSurface": { "rustRoots": [ "auths", @@ -66,7 +66,7 @@ "entries": [ { "id": "auths.core.protocol", - "version": 13, + "version": 15, "classification": "frozen-meaning", "categories": [ "protocol-versions", @@ -83,11 +83,11 @@ "core/crates/auths-verifier/src", "core/spec/v1" ], - "sha256": "d84577baa59755f65364bf317bcbbbb5ffe810937b39ee961935d1dc43933a10" + "sha256": "68a9f58cbf304c933e3ee1f883166c946248c071a836f4b9b0e819a892d60527" }, { "id": "auths.frozen-bytes/architecture/dependency-graph.json", - "version": 12, + "version": 14, "classification": "frozen-bytes", "categories": [ "canonical-generated-evidence" @@ -95,7 +95,7 @@ "owners": [ "architecture/dependency-graph.json" ], - "sha256": "f8fbd1f49da0c12529cd30a3cf17cf1c4b9449d0df88b6d6411ddbf8ba27965a" + "sha256": "413f4b23da5dcd172d15f8a1df0e1d3db49470c9aec88cdbd562c680da99645d" }, { "id": "auths.frozen-bytes/bindings/wasm/auths-proof-wasm/identity-abi-v1.json", @@ -495,7 +495,7 @@ }, { "id": "auths.identity.protocol", - "version": 5, + "version": 7, "classification": "frozen-meaning", "categories": [ "identity-protocol-versions", @@ -518,7 +518,7 @@ "core/fixtures/identity/v1/vectors.json", "core/spec/identity/v1" ], - "sha256": "7e833238d1558a227db17b0167b511250934bee700f6c04c5646e4a01477a7dd" + "sha256": "643bd65e19c0496976f12a3fbd7f2377ef5ec695379fdc0471c819a3e459ccf0" }, { "id": "auths.modular-components", @@ -556,7 +556,7 @@ }, { "id": "auths.portable-abi-bindings", - "version": 19, + "version": 25, "classification": "frozen-meaning", "categories": [ "portable-abi", @@ -564,6 +564,8 @@ "binding-contracts" ], "owners": [ + "bindings/python/native-abi-v2.json", + "bindings/python/python/auths", "bindings/python/src", "bindings/typescript/src", "bindings/wasm/auths-proof-wasm/authoring-abi-v1.json", @@ -571,7 +573,7 @@ "core/crates/auths-model/src/lib.rs", "core/spec/v1/auths-proof.cddl" ], - "sha256": "e2e54ea5616ced57ba7099ec0321a34db7ed621d4d9e86dfc3c8b538b7eb5a3f" + "sha256": "b29177d7759a6a87bd5e43af3d61c6ed31ebde1b53f40bde5f2ea64a0d3a8032" }, { "id": "auths.product.bounded-domains", @@ -648,7 +650,7 @@ }, { "id": "auths.product.public-sdk-contract", - "version": 16, + "version": 20, "classification": "frozen-meaning", "categories": [ "rust-sdk-contract", @@ -665,7 +667,7 @@ "product/runtime/auths-runtime/src", "product/sdk/auths-sdk/src" ], - "sha256": "09b11d8db99b1bf7f85a8ae4562b7ee8449181548759f5f3beea78753018ea89" + "sha256": "0b298ed55abba0bf776d1aec4a41a1aaced5b4b7eeabcc041817b0240ef81f43" }, { "id": "auths.product.receipts", @@ -700,7 +702,7 @@ }, { "id": "auths.release.public-surface", - "version": 50, + "version": 56, "classification": "release-metadata", "categories": [ "package-names", @@ -782,7 +784,7 @@ "xtask/src/release_control.rs", "xtask/src/semantic_freeze.rs" ], - "sha256": "f887a23558e038b1d125f71ed5a52aa77ba9f475f4c6047d08cd505c98a81812" + "sha256": "8972230f8ad42e10b8bd215c5b173f0d6805cfc308040e675ace3ce425d2965e" } ] } diff --git a/xtask/src/checks.rs b/xtask/src/checks.rs index 3b202b48..682f8661 100644 --- a/xtask/src/checks.rs +++ b/xtask/src/checks.rs @@ -290,11 +290,35 @@ pub(crate) fn python_wheel_smoke() -> Result<(), String> { } else { virtual_environment.join("bin/python") }; - command(path_text(&python)?, &["-m", "pip", "install", "pytest"])?; + command( + path_text(&python)?, + &[ + "-m", + "pip", + "install", + "pytest==9.0.2", + "pytest-asyncio==1.3.0", + ], + )?; command( path_text(&python)?, &["-m", "pip", "install", path_text(&wheel)?], )?; + command( + path_text(&python)?, + &["bindings/python/tools/check_wheel.py", path_text(&wheel)?], + )?; + command( + path_text(&python)?, + &["bindings/python/tools/check_public_api.py"], + )?; + command( + path_text(&python)?, + &[ + "bindings/python/external/full_workflow_consumer.py", + "target/binding-vectors", + ], + )?; command_in( path_text(&python)?, &["-m", "pytest", "tests"], diff --git a/xtask/src/semantic_freeze.rs b/xtask/src/semantic_freeze.rs index 09b9fb92..8fb03728 100644 --- a/xtask/src/semantic_freeze.rs +++ b/xtask/src/semantic_freeze.rs @@ -4,7 +4,7 @@ use crate::*; const INVENTORY_PATH: &str = "release/semantic-freeze.json"; const INVENTORY_SCHEMA: &str = "auths.semantic-freeze/1"; -const FREEZE_VERSION: u64 = 50; +const FREEZE_VERSION: u64 = 56; const PUBLIC_RUST_ROOTS: [&str; 10] = [ "auths", "auths-byte-channel", @@ -163,7 +163,7 @@ fn generate_inventory() -> Result { let mut entries = vec![ freeze_entry( "auths.core.protocol", - 13, + 15, FreezeClassification::FrozenMeaning, &[ "protocol-versions", @@ -183,7 +183,7 @@ fn generate_inventory() -> Result { )?, freeze_entry( "auths.identity.protocol", - 5, + 7, FreezeClassification::FrozenMeaning, &[ "identity-protocol-versions", @@ -242,10 +242,12 @@ fn generate_inventory() -> Result { )?, freeze_entry( "auths.portable-abi-bindings", - 19, + 25, FreezeClassification::FrozenMeaning, &["portable-abi", "authoring-abi", "binding-contracts"], vec![ + "bindings/python/native-abi-v2.json".to_owned(), + "bindings/python/python/auths".to_owned(), "core/crates/auths-model/src/lib.rs".to_owned(), "core/spec/v1/auths-proof.cddl".to_owned(), "bindings/wasm/auths-proof-wasm/authoring-abi-v1.json".to_owned(), @@ -256,7 +258,7 @@ fn generate_inventory() -> Result { )?, freeze_entry( "auths.product.public-sdk-contract", - 16, + 20, FreezeClassification::FrozenMeaning, &[ "rust-sdk-contract", @@ -362,7 +364,7 @@ fn generate_inventory() -> Result { for (id, path) in frozen_byte_inventories()? { let version = match path.as_str() { - "architecture/dependency-graph.json" => 12, + "architecture/dependency-graph.json" => 14, "bindings/wasm/auths-proof-wasm/identity-abi-v1.json" => 3, "core/fixtures/v1/manifest.json" => 3, "formal/assurance-manifest-v1.toml" @@ -415,7 +417,7 @@ fn generate_inventory() -> Result { ]); entries.push(freeze_entry( "auths.release.public-surface", - 50, + 56, FreezeClassification::ReleaseMetadata, &[ "package-names", @@ -783,14 +785,50 @@ fn visit_files( )); } if metadata.is_dir() { + if generated_owner_directory(&path) { + continue; + } visit_files(&path, visitor)?; } else if metadata.is_file() { + if generated_owner_file(&path) { + continue; + } visitor(&path)?; } } Ok(()) } +fn generated_owner_directory(path: &Path) -> bool { + matches!( + path.file_name().and_then(|name| name.to_str()), + Some( + ".git" + | ".lake" + | ".mypy_cache" + | ".pytest_cache" + | ".ruff_cache" + | ".venv" + | "__pycache__" + | "node_modules" + | "target" + ) + ) +} + +fn generated_owner_file(path: &Path) -> bool { + if matches!( + path.file_name().and_then(|name| name.to_str()), + Some(".DS_Store" | ".coverage") + ) { + return true; + } + matches!( + path.extension().and_then(|extension| extension.to_str()), + Some("dll" | "dylib" | "pyc" | "pyd" | "pyo" | "so") + ) +} + fn read_owned_file(path: &Path) -> Result, String> { fs::read(path).map_err(|error| format!("could not read {}: {error}", path.display())) } @@ -1042,4 +1080,14 @@ mod tests { let error = validate_inventory(&inventory).expect_err("duplicate must fail"); assert!(error.contains("duplicate semantic freeze identity")); } + + #[test] + fn generated_owner_artifacts_are_excluded() { + assert!(generated_owner_directory(Path::new("auths/__pycache__"))); + assert!(generated_owner_directory(Path::new("auths/node_modules"))); + assert!(generated_owner_file(Path::new("auths/module.pyc"))); + assert!(generated_owner_file(Path::new("auths/_native.abi3.so"))); + assert!(!generated_owner_directory(Path::new("auths/profiles"))); + assert!(!generated_owner_file(Path::new("auths/verify.py"))); + } }