From aefa2d93ed8a2e9906e4e0bf7df94e53afd9f977 Mon Sep 17 00:00:00 2001 From: kironovlaziz-del Date: Mon, 2 Nov 2026 11:13:14 +0000 Subject: [PATCH 1/2] Add Provenza: Agent Manifest issuer (composition-only, hybrid COSE) --- .../provenza-agent-manifest-conformance.yml | 44 ++++++++ README.md | 1 + .../provenza-agent-manifest/README.md | 54 +++++++++ .../provenza-agent-manifest/integration.yaml | 18 +++ .../src/verify_provenza_manifest.py | 104 ++++++++++++++++++ .../tests/agent-1-manifest.cose | Bin 0 -> 5132 bytes .../tests/test_verify_provenza_manifest.py | 57 ++++++++++ .../tests/trusted_keys.json | 4 + marketplace/catalog.json | 26 ++++- 9 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/provenza-agent-manifest-conformance.yml create mode 100644 integrations/provenza-agent-manifest/README.md create mode 100644 integrations/provenza-agent-manifest/integration.yaml create mode 100644 integrations/provenza-agent-manifest/src/verify_provenza_manifest.py create mode 100644 integrations/provenza-agent-manifest/tests/agent-1-manifest.cose create mode 100644 integrations/provenza-agent-manifest/tests/test_verify_provenza_manifest.py create mode 100644 integrations/provenza-agent-manifest/tests/trusted_keys.json diff --git a/.github/workflows/provenza-agent-manifest-conformance.yml b/.github/workflows/provenza-agent-manifest-conformance.yml new file mode 100644 index 0000000..72960e4 --- /dev/null +++ b/.github/workflows/provenza-agent-manifest-conformance.yml @@ -0,0 +1,44 @@ +name: provenza-agent-manifest conformance +on: + push: + paths: + - "integrations/provenza-agent-manifest/**" + - ".github/workflows/provenza-agent-manifest-conformance.yml" + pull_request: + paths: + - "integrations/provenza-agent-manifest/**" + - ".github/workflows/provenza-agent-manifest-conformance.yml" + schedule: + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +jobs: + conformance: + name: ${{ matrix.sdk }} agent-manifest, Python ${{ matrix.python }} + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.12", "3.13"] + # tested_against, and the latest release (drift detection) + sdk: ["pinned", "latest"] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 + with: + python-version: ${{ matrix.python }} + - name: Install agent-manifest + run: | + if [ "${{ matrix.sdk }}" = pinned ]; then + v=$(sed -n 's/^ agent-manifest: "\([^"]*\)"$/\1/p' integrations/provenza-agent-manifest/integration.yaml) + test -n "$v" + python -m pip install "agent-manifest==$v" pytest + else + python -m pip install agent-manifest pytest + fi + # This integration verifies Agent Manifests; it emits no TRACE record. + - name: Integration tests against the sample manifest + run: python -m pytest integrations/provenza-agent-manifest/tests -q diff --git a/README.md b/README.md index 1f1749d..377e984 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ TRACE only works as a standard if it is genuinely neutral. Integrations are list | [Parmana](integrations/parmana-server/) | Parmana Systems | trace | verified | | [PIC Standard MCP Guard](integrations/pic-standard-mcp-guard/) | PIC Standard | trace | verified | | [Observed-effect references](integrations/probityai-observed-effect/) | probityai | trace | community | +| [Provenza](integrations/provenza-agent-manifest/) | Provenza | agent-manifest | community | | [ramen-ai cMCP Adapter](integrations/ramen-ai-cmcp/) | ramen-ai | cmcp, trace | verified | | [SAGE AgenTrust Bridge](integrations/sage-agenttrust/) | SAGE | cmcp, trace | community | | [Agent Sentinel](integrations/sentinel/) | a1k7 | trace | community | diff --git a/integrations/provenza-agent-manifest/README.md b/integrations/provenza-agent-manifest/README.md new file mode 100644 index 0000000..b3244c6 --- /dev/null +++ b/integrations/provenza-agent-manifest/README.md @@ -0,0 +1,54 @@ +# Provenza Agent Manifest issuer + +[Provenza](https://github.com/kironovlaziz-del/provenza) is a self-hosted AI and agent governance server. It issues an Agent Manifest (specification v0.2) for every agent it governs, and this directory verifies such a manifest offline with the released `agent-manifest` SDK. + +## What it does + +Provenza builds the manifest from what it governs, signs it with the organization's audit key, and hands it out on the agent's page (**Agent Manifest**) or at `GET /api/v1/agents/{id}/manifest` (JSON: the payload, the COSE envelope, the signing keys) and `GET /api/v1/agents/{id}/manifest.cose`. + +| Manifest field | What Provenza binds | +|---|---| +| envelope | `COSE_Sign` with two signers, Ed25519 and ML-DSA-65 (hybrid), `crypto_profile: standard` | +| `profile` | `composition-only`: Provenza is the policy gate, not the agent | +| `agent_id` | the SPIFFE ID the agent attested with, else `spiffe:///org//agent/` | +| `issuer` | `spiffe:///org//provenza` | +| `artifacts.tool_manifest` | the approved MCP tools Provenza serves the agent: per tool, the hash of `{input_schema, output_schema}` and of the description (RFC 8785, as cMCP projects its catalog), and the Merkle `catalog_hash`. `rug_pull_policy: require-reapproval` (a definition changed upstream is withheld until re-approved), `allow_dynamic_registration: false` | +| `artifacts.decision_trace` | `merkle-log`: the root of the organization's latest signed audit checkpoint, its size and window, the checkpoint key's fingerprint | +| `hitl_record.required` | `true` when the agent's policies hold actions for a person's approval | +| `previous_manifest_id` | the manifest it replaces; a new one is issued when the tools, the approval requirement or the signing key change, or near expiry (90 days) | + +## What it does not claim + +- **Not a complete manifest.** System prompt, model identity, RAG corpus, memory, supply chain and delegation chain are declared in `unbound_artifacts`: Provenza does not observe them. The SDK's verdict is `INCOMPLETE`, never `VALID`. +- **No policy bundle.** The specification names Cedar, Rego and AGT YAML; Provenza's policy format is none of them, so `policy_bundle` stays unbound. +- **A partial tool catalog is never bound.** When an agent is registered for tools that Provenza does not serve as approved MCP tools, `tool_manifest` is unbound rather than listing only some tools. +- **No hardware attestation, no transparency log.** The keys are software keys held by the Provenza server (`audit_key_sealed: false`); no `attestation` block, no `transparency_log_entry`. Conformance levels do not apply to a composition-only manifest. +- **No HITL approvals attached.** Provenza signs approvals per held action; the manifest states only whether approvals are required. + +## Usage + +```bash +pip install agent-manifest==0.15.0 +python src/verify_provenza_manifest.py agent-7-manifest.cose --ed25519 --ml-dsa +``` + +`--ed25519` and `--ml-dsa` are the organization's audit public keys as Provenza lists them (`GET /api/v1/audit/keys`, standard base64); `--keys` takes a `{kid hex: base64url key}` JSON instead, as `GET /api/v1/agents/{id}/manifest` returns it. Obtain the keys from a source you trust independently of the manifest file. The `manifest verify` CLI of agent-manifest 0.15.0 takes a single Ed25519 key, so it cannot check both signers of a hybrid envelope; the script calls the SDK directly. + +The script checks both COSE signatures (`verify_cose_manifest`), recomputes the tool catalog root (`build_catalog_tree`) and prints the verification engine's verdict (`verify_manifest`). It exits 0 when the signatures verify and the verdict is `VALID` or `INCOMPLETE`. + +## Reproduction + +```bash +python -m venv .venv && . .venv/bin/activate +pip install agent-manifest==0.15.0 pytest +python src/verify_provenza_manifest.py tests/agent-1-manifest.cose --keys tests/trusted_keys.json +python -m pytest tests -q +``` + +`tests/agent-1-manifest.cose` was issued by Provenza for an agent with one approved MCP tool (`files.read`); `tests/trusted_keys.json` holds the two public keys that signed it. Expected output: `signatures_verified: true`, `signers: 2`, `catalog_hash_matches: true`, `verdict: INCOMPLETE` (`EXPIRED` after 2027-01-31). The tests also show that a changed payload and an unknown key fail. + +To issue one yourself: install Provenza ([self-hosting guide](https://github.com/kironovlaziz-del/provenza/blob/main/docs/self-hosting.md)), register an agent, approve an MCP server's tool for it, and press **Agent Manifest** on the agent's page. + +## Source and licence + +Provenza and this directory are Apache-2.0. The issuer is `backend/app/services/agent_manifest.py` in the Provenza repository. diff --git a/integrations/provenza-agent-manifest/integration.yaml b/integrations/provenza-agent-manifest/integration.yaml new file mode 100644 index 0000000..9c51483 --- /dev/null +++ b/integrations/provenza-agent-manifest/integration.yaml @@ -0,0 +1,18 @@ +name: Provenza +vendor: Provenza +integrates_with: + - agent-manifest +description: >- + Issues a composition-only Agent Manifest v0.2 per governed agent, signed as a hybrid + Ed25519 + ML-DSA-65 COSE_Sign, binding its approved MCP tool catalog and audit-log root. +maintainer: + github: kironovlaziz-del +repository: https://github.com/kironovlaziz-del/provenza +license: Apache-2.0 +tier: community +marketplace: + category: Identity & trust + mark: PVZ + keywords: [agent-governance, mcp, post-quantum, self-hosted, audit] +tested_against: + agent-manifest: "0.15.0" diff --git a/integrations/provenza-agent-manifest/src/verify_provenza_manifest.py b/integrations/provenza-agent-manifest/src/verify_provenza_manifest.py new file mode 100644 index 0000000..99ea694 --- /dev/null +++ b/integrations/provenza-agent-manifest/src/verify_provenza_manifest.py @@ -0,0 +1,104 @@ +"""Verify an Agent Manifest issued by Provenza, with the agent-manifest SDK. + +Provenza issues a composition-only v0.2 manifest per agent, signed as a hybrid +COSE_Sign (Ed25519 + ML-DSA-65) by the organization's audit key. This script +checks, offline: + + 1. both COSE signatures, against keys you supply (verify_cose_manifest); + 2. the tool catalog root, recomputed from the bound tools (build_catalog_tree); + 3. the SDK verification engine's verdict (verify_manifest). For a + composition-only manifest the expected verdict is INCOMPLETE: the + signature is authentic and the unbound artifacts are declared. + +Keys: either a JSON object {kid hex: base64url public key} (--keys), or the +organization's audit public keys as Provenza shows them, standard base64 +(--ed25519, --ml-dsa; GET /api/v1/audit/keys). Take them from a source you +trust independently of the manifest file. + +Exit code 0 when the signatures verify and the verdict is VALID or INCOMPLETE, +1 otherwise. +""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import sys +from pathlib import Path +from typing import Any + +from agent_manifest import ( + OverallResult, + RevocationStore, + VerificationContext, + verify_cose_manifest, + verify_manifest, +) +from agent_manifest._merkle import build_catalog_tree +from agent_manifest.models import ToolEntry + +ACCEPTED = (OverallResult.VALID, OverallResult.INCOMPLETE) + + +def trusted_keys_from_b64(*keys: str | None) -> dict[str, str]: + """{kid hex: base64url key} from standard-base64 raw public keys. The COSE + kid of each signer is SHA-256 of its raw public key.""" + out = {} + for key in keys: + if key: + raw = base64.b64decode(key) + out[hashlib.sha256(raw).hexdigest()] = base64.urlsafe_b64encode(raw).decode().rstrip("=") + return out + + +def check(envelope: bytes, trusted_keys: dict[str, str]) -> dict[str, Any]: + """Run the three checks; return a report (raises on a bad signature).""" + cose = verify_cose_manifest(envelope, trusted_keys) + manifest = cose.manifest + report: dict[str, Any] = { + "manifest_id": manifest["manifest_id"], + "agent_id": manifest["agent_id"], + "issuer": manifest["issuer"], + "signatures_verified": bool(cose.verified), + "signers": len(cose.signatures), + "profile": manifest.get("profile"), + "unbound_artifacts": manifest.get("unbound_artifacts", []), + } + tm = manifest.get("artifacts", {}).get("tool_manifest") + if tm is not None: + root = build_catalog_tree([ToolEntry.model_validate(t) for t in tm["tools"]]) + report["tools"] = [t["tool_name"] for t in tm["tools"]] + report["catalog_hash_matches"] = root == tm["catalog_hash"] + result = verify_manifest(envelope, VerificationContext(trusted_keys=trusted_keys), RevocationStore()) + report["verdict"] = result.result.value + return report + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + p.add_argument("manifest", help="the .cose file (Provenza: agent page -> Agent Manifest)") + p.add_argument("--keys", help="JSON {kid hex: base64url public key}") + p.add_argument("--ed25519", help="the audit key's Ed25519 public key, base64") + p.add_argument("--ml-dsa", help="the audit key's ML-DSA-65 public key, base64") + args = p.parse_args(argv) + + keys = json.loads(Path(args.keys).read_text()) if args.keys else {} + keys.update(trusted_keys_from_b64(args.ed25519, args.ml_dsa)) + if not keys: + p.error("give the signing keys: --keys or --ed25519/--ml-dsa") + + try: + report = check(Path(args.manifest).read_bytes(), keys) + except Exception as e: # a bad signature, an unknown key, a malformed envelope + print(json.dumps({"error": type(e).__name__, "detail": str(e)}, indent=2)) + return 1 + print(json.dumps(report, indent=2)) + ok = report["signatures_verified"] and report.get("catalog_hash_matches", True) \ + and OverallResult(report["verdict"]) in ACCEPTED + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/integrations/provenza-agent-manifest/tests/agent-1-manifest.cose b/integrations/provenza-agent-manifest/tests/agent-1-manifest.cose new file mode 100644 index 0000000000000000000000000000000000000000..bf03bd5d98934b5b388da663ce5e99197f60c6cb GIT binary patch literal 5132 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 GmHrD4n#NQB literal 0 HcmV?d00001 diff --git a/integrations/provenza-agent-manifest/tests/test_verify_provenza_manifest.py b/integrations/provenza-agent-manifest/tests/test_verify_provenza_manifest.py new file mode 100644 index 0000000..a83fb28 --- /dev/null +++ b/integrations/provenza-agent-manifest/tests/test_verify_provenza_manifest.py @@ -0,0 +1,57 @@ +"""The sample manifest was issued by Provenza (an MCP-served agent with one +approved tool). The tests run offline against it.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import cbor2 +import pytest + +SRC = Path(__file__).resolve().parents[1] / "src" +sys.path.insert(0, str(SRC)) + +import verify_provenza_manifest as v # noqa: E402 + +HERE = Path(__file__).resolve().parent +ENVELOPE = (HERE / "agent-1-manifest.cose").read_bytes() +KEYS = json.loads((HERE / "trusted_keys.json").read_text()) + + +def test_signatures_catalog_and_profile(): + report = v.check(ENVELOPE, KEYS) + assert report["signatures_verified"] and report["signers"] == 2 # Ed25519 + ML-DSA-65 + assert report["catalog_hash_matches"] and report["tools"] == ["files.read"] + assert report["profile"] == "composition-only" + assert "system_prompt" in report["unbound_artifacts"] and "policy_bundle" in report["unbound_artifacts"] + + +def test_verdict_is_incomplete_not_valid(): + # a composition-only manifest authenticates; it never claims to be complete. + # (After expires_at the engine reports the expiry instead.) + report = v.check(ENVELOPE, KEYS) + assert report["verdict"] in ("INCOMPLETE", "EXPIRED") + + +def test_a_changed_payload_fails(): + tag = cbor2.loads(ENVELOPE) + parts = list(tag.value) + parts[2] = parts[2].replace(b"files.read", b"files.rm!!") + forged = cbor2.dumps(cbor2.CBORTag(tag.tag, parts), canonical=True) + with pytest.raises(Exception): + v.check(forged, KEYS) + + +def test_unknown_keys_fail(): + with pytest.raises(Exception): + v.check(ENVELOPE, {"00" * 32: "AAAA"}) + + +def test_cli_exit_codes(tmp_path, capsys): + keys = tmp_path / "keys.json" + keys.write_text(json.dumps(KEYS)) + assert v.main([str(HERE / "agent-1-manifest.cose"), "--keys", str(keys)]) in (0, 1) + out = json.loads(capsys.readouterr().out) + assert out["signatures_verified"] is True diff --git a/integrations/provenza-agent-manifest/tests/trusted_keys.json b/integrations/provenza-agent-manifest/tests/trusted_keys.json new file mode 100644 index 0000000..937031f --- /dev/null +++ b/integrations/provenza-agent-manifest/tests/trusted_keys.json @@ -0,0 +1,4 @@ +{ + "2c503b6f0265161cbdf20c659ace2e9d3b8a6bc92136f12d42c79348802a826b": "MQXKxDz5dznce11ass7P7NQS4Q1g_70kcoXye52Ox6I", + "9e309a0180a58ea51cdcb2ae2d5f2bb1c517995c534d5025f9e314ce5a35a4b8": "n_iGDjduF-Yy0gdvLb2ghFkr5v9BmSv91-_I_-U1JYMle-IkHyBJKUJgtQeYcVBYpwV-2Y1SVn2n-E6S2VX_xYO-RmfVWHhuqmJvW4vV4qvwrBlhV-KLiSb5pS2wIA9XZFLFD25JDhAwhswGk3CXPcShQP-ukW4QhtUToj74-c8pC6-ff7QBavhjlkpxgFgge_SyqCvji0sfK2e2JVsdQYXbqe65_nN0WbNJd9Ep3AsiHP-JJJQlv2ogUkrxHEkKXa7itc4gWZw5hzzWOW76E36WwTdQ9p85lFgjeRjnGrSyDykR17tIIWPh8bIbPg84du2Ns0rdMdviSdLMCqeuVN8bNi_E2YKBQYfO_h5dICQQk-YsWQZ2eCJYfhQnN2Uy-yv3vgQKyGaKS4i7pgIVHZ_zi-Ra5NpHIPfjzaCWqkw8ZMLFkbJKXeXzjocArwnZX2iWcsxeQ1yfhvzH-ic-ZFxxYAegJb3myc6XR5WcKtmkvJdu8J5oum42Y4usObVYobTHmxxj4MbTIuJU-xGhy3kgDxpAmWftdxobd8n7DHY3rx3NB7ROOhOpSP5c-LIW81Y9_RjxW3O95U8Hdwqzd9rCdrNp0Qy5t9n7s4cHHMZxv8-bbis6FIKHKl_1m85cNJpEkWXgxepnhTGQmbBIsrjKvQ3oxIFZAp0m-DdO12qk1jrAGUQzvdwel4wV7CksU4Eoeg14mLbV8IFHvfyCcCy9ozTa2d7cc_pb2SK7VkYnf3AeQkNG-gA4okAn29AGvx-6mY6b4WBGephBUcq2RZD6RYnrJTK_R4u4tQoMcuv7ra-g4Gq6dNgJ8l2E-6pRJF3CN4ShkgRRJwVZ9gYMNR-TYCowNYsVMe1LnwW7m7dCDTYhS5quYNtQ1Xz_qXNjESPXswxsPN_pC3E2vbEFa3XLH3QJsOxGoiaAnQJhVqFzLqSN3LzsGt8VPSNfuJHOFJqXO-NWbcCZ_JWOjxyM3_X0454D3YpFRzzmRU4FP6E_PsN6KStt2QKPxGUBF26nopEloHyXzzdHkgXsRFx25q1OXlMGsEtdlNP5rDMEIPuLTZS-tNyCwBVd6d-jmCnyYGYDGgII_vWwK6m2n1UfqoOJy0dzLH09iDILpxmvoDyoPI1i-rdGijVHBjXYBu81eY0xlWGViAS6MszbYOXDN3AVPoNY6D-Tk_frJnRUcp4SBp6DhOtmRq_YhUmn79APf5cfUXVV0Xbf6rpWNheEDo3oTI-gkuq7qEnA25sCmqg9k4NbFIU4GPV1EuyvH5BXx82iUFo7SL9iU_XN0vppinybOK8cdt2mU5FNGa90h7e_oKUQDqNu9UOUeQNMyV-kDLHoOOdb3pNNwhYQp9gEXSp5pLYJt7dnHFEWTKSVVfyX5bt_uqla4Hi8nv1Uwkn1eNdxxynMcbHgSpDm0iIFmqm8YLr6ox6QzQbCrzw-AL4MafGwh6C6tVOJ9juq-cIPd_7o3yKLNkPrawAQ91ODxqcRylI6SMzTD0qC1DCBkPpoN_TWxVZPTncWy1nL8U-mqDiSgocZJQNWOZqD0eIRd11xSciiZeZm6dCc29RCFsSqgZFB_UbaBFUT8pW2iV2h87xjkPSl-Fe8yS65Txsfwziq32gPPBB8mjYe1dhOFehbNndcrovyX72_jyJKDkLZTNMjWjKdobDRTvlh52NR7Pkkq6qDV0o8FOifA-38Mg2XT6xCzJ-4pydxes_e8F-fpmlub8n5T6PWfETKCAMZB7oZX2rsBwh6AuaUtHY8tOJycGeYLFKFp3aEw3HnC6MJJAr6Vd_J97xP6dab_xOcEDyoo-Y0f3LwUMQlAUXu9zxpdRC7iSmrAmRuuLylO35_r8IYMFNyz9LLT2As5TqZHJdPtbRhDhnQ5pLpgw-MyP93EDiI2zl_H3PcDTbbQaFu_DofnGh6yqCj1OkGKEytt_RyKJsPJB6-5dZ7meBZfNqCQtFodtuR9DCUFseBIJmY86C8WfFKdtvfqpxYSXIOgaG8mFxJnT-uMIRS66OUGUDFDY772macFM44QoYU0y82TaKpN9lTXB9yCLT1YS8ny0OHu3mpJ4ayixXlVh4KocagZyAROoQa_So0Nm7aqk_550zfUhX07NBWiYVqrZPrpKS-WbwWHNihy1Y30prp7yl8U_QyJbdYjO9YqTwRk4YbYMUf-g8LG8F7_rh5zn5mWF3l9GsnqviD-zHmGG6_og7YR2Ip6sgQdyHJUuyKn_w2W6pdPxCyMH1nKiIjbsheKmHbBvfcJ8r4cExo_cT3DT8ZjOTZcRR8IUppLJgziTbK3642gHscL-RAi4L6knbO17X-jUyfXUGgT-qkkzH4XhjMiYP_qivoSPkrKX4mOMeud2ZOXFe6hp6YYfSv-efpbxBafGXna0JoNrkXTFBq2oqYrpIuTWF9WwFiQI7yY_l5tI9QIPSstYJM4qJdKqvrZvvH4kkesv361GgEy6o5bwxfSswCWSR9djVX_4Vm13s2aWEqrjm_GipYx-HwrQcUFVv-vpaF_4opwmZTZ3TIoydKohqhAWFZ4s4ypw_J4KR5voF0hprKW_iOmVbyDN1ySZ77k3LnGWDYaNEKRNIoyM0" +} \ No newline at end of file diff --git a/marketplace/catalog.json b/marketplace/catalog.json index 5d8a493..2d87160 100644 --- a/marketplace/catalog.json +++ b/marketplace/catalog.json @@ -1,6 +1,6 @@ { "catalog_version": 1, - "count": 50, + "count": 51, "integrations": [ { "name": "Claude Code", @@ -831,6 +831,30 @@ "trace" ] }, + { + "name": "Provenza", + "package_name": "Provenza", + "vendor": "Provenza", + "description": "Issues a composition-only Agent Manifest v0.2 per governed agent, signed as a hybrid Ed25519 + ML-DSA-65 COSE_Sign, binding its approved MCP tool catalog and audit-log root.", + "path": "integrations/provenza-agent-manifest", + "url": "https://github.com/agentrust-io/integrations/tree/main/integrations/provenza-agent-manifest", + "homepage": null, + "repository": "https://github.com/kironovlaziz-del/provenza", + "tier": "community", + "stack": [ + "Agent Manifest" + ], + "category": "Identity & trust", + "mark": "PVZ", + "featured": null, + "keywords": [ + "agent-governance", + "mcp", + "post-quantum", + "self-hosted", + "audit" + ] + }, { "name": "ramen-ai cMCP Adapter", "package_name": "ramen-ai cMCP Adapter", From 13ef3e051b0b13a862164b187c66e39e32ef2e9d Mon Sep 17 00:00:00 2001 From: kironovlaziz-del Date: Mon, 2 Nov 2026 23:27:43 +0000 Subject: [PATCH 2/2] provenza-agent-manifest: require both hybrid signers; pin exit codes --- .../provenza-agent-manifest-conformance.yml | 2 +- .../provenza-agent-manifest/README.md | 6 +- .../src/verify_provenza_manifest.py | 27 +++++- .../tests/test_verify_provenza_manifest.py | 87 ++++++++++++++++--- 4 files changed, 103 insertions(+), 19 deletions(-) diff --git a/.github/workflows/provenza-agent-manifest-conformance.yml b/.github/workflows/provenza-agent-manifest-conformance.yml index 72960e4..924aa93 100644 --- a/.github/workflows/provenza-agent-manifest-conformance.yml +++ b/.github/workflows/provenza-agent-manifest-conformance.yml @@ -26,7 +26,7 @@ jobs: sdk: ["pinned", "latest"] runs-on: ubuntu-latest steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 with: python-version: ${{ matrix.python }} diff --git a/integrations/provenza-agent-manifest/README.md b/integrations/provenza-agent-manifest/README.md index b3244c6..3d42460 100644 --- a/integrations/provenza-agent-manifest/README.md +++ b/integrations/provenza-agent-manifest/README.md @@ -34,7 +34,9 @@ python src/verify_provenza_manifest.py agent-7-manifest.cose --ed25519 `--ed25519` and `--ml-dsa` are the organization's audit public keys as Provenza lists them (`GET /api/v1/audit/keys`, standard base64); `--keys` takes a `{kid hex: base64url key}` JSON instead, as `GET /api/v1/agents/{id}/manifest` returns it. Obtain the keys from a source you trust independently of the manifest file. The `manifest verify` CLI of agent-manifest 0.15.0 takes a single Ed25519 key, so it cannot check both signers of a hybrid envelope; the script calls the SDK directly. -The script checks both COSE signatures (`verify_cose_manifest`), recomputes the tool catalog root (`build_catalog_tree`) and prints the verification engine's verdict (`verify_manifest`). It exits 0 when the signatures verify and the verdict is `VALID` or `INCOMPLETE`. +The script verifies the COSE signatures (`verify_cose_manifest`), recomputes the tool catalog root (`build_catalog_tree`) and prints the verification engine's verdict (`verify_manifest`). It exits 0 only when the verified signers include both an Ed25519 signer (algorithm -19, or -8) and an ML-DSA-65 signer (-49), the catalog root matches, and the verdict is `VALID` or `INCOMPLETE`. + +The hybrid signature is the conjunction of the two. `COSE_Sign` signers do not cover each other, so an envelope stripped down to one of them still verifies against that key; the script refuses it, whichever half remains, and names the missing family in `missing_signers`. For the same reason it refuses the Ed25519-only `COSE_Sign1` that a Provenza server without ML-DSA-65 issues: such a manifest is not the hybrid one this listing describes. ## Reproduction @@ -45,7 +47,7 @@ python src/verify_provenza_manifest.py tests/agent-1-manifest.cose --keys tests/ python -m pytest tests -q ``` -`tests/agent-1-manifest.cose` was issued by Provenza for an agent with one approved MCP tool (`files.read`); `tests/trusted_keys.json` holds the two public keys that signed it. Expected output: `signatures_verified: true`, `signers: 2`, `catalog_hash_matches: true`, `verdict: INCOMPLETE` (`EXPIRED` after 2027-01-31). The tests also show that a changed payload and an unknown key fail. +`tests/agent-1-manifest.cose` was issued by Provenza for an agent with one approved MCP tool (`files.read`); `tests/trusted_keys.json` holds the two public keys that signed it. Expected output: `signatures_verified: true`, `signers: 2`, `algorithms: [-49, -19]`, `hybrid: true`, `catalog_hash_matches: true`, `verdict: INCOMPLETE`, exit code 0. After 2027-01-31 the verdict is `EXPIRED` and the exit code 1. The tests fix the clock for the validity window, so they assert one exit code each, and they show that a changed payload, an unknown key and an envelope with only one of the two signers fail. To issue one yourself: install Provenza ([self-hosting guide](https://github.com/kironovlaziz-del/provenza/blob/main/docs/self-hosting.md)), register an agent, approve an MCP server's tool for it, and press **Agent Manifest** on the agent's page. diff --git a/integrations/provenza-agent-manifest/src/verify_provenza_manifest.py b/integrations/provenza-agent-manifest/src/verify_provenza_manifest.py index 99ea694..7d16415 100644 --- a/integrations/provenza-agent-manifest/src/verify_provenza_manifest.py +++ b/integrations/provenza-agent-manifest/src/verify_provenza_manifest.py @@ -4,7 +4,10 @@ COSE_Sign (Ed25519 + ML-DSA-65) by the organization's audit key. This script checks, offline: - 1. both COSE signatures, against keys you supply (verify_cose_manifest); + 1. both COSE signatures, against keys you supply (verify_cose_manifest), + and that both are there: the hybrid signature is the conjunction of an + Ed25519 and an ML-DSA-65 signer, so an envelope that verifies with one + of them alone is refused, whichever one remains; 2. the tool catalog root, recomputed from the bound tools (build_catalog_tree); 3. the SDK verification engine's verdict (verify_manifest). For a composition-only manifest the expected verdict is INCOMPLETE: the @@ -15,8 +18,8 @@ (--ed25519, --ml-dsa; GET /api/v1/audit/keys). Take them from a source you trust independently of the manifest file. -Exit code 0 when the signatures verify and the verdict is VALID or INCOMPLETE, -1 otherwise. +Exit code 0 when both signatures verify, the catalog root matches and the +verdict is VALID or INCOMPLETE; 1 otherwise. """ from __future__ import annotations @@ -41,6 +44,19 @@ ACCEPTED = (OverallResult.VALID, OverallResult.INCOMPLETE) +# The signer families the hybrid signature consists of, by COSE algorithm. +# -8 (EdDSA) and -19 (Ed25519) both identify Ed25519 (RFC 9864). +REQUIRED_FAMILIES = { + "ed25519": frozenset({-8, -19}), + "ml-dsa-65": frozenset({-49}), +} + + +def missing_families(algorithms: list[int]) -> list[str]: + """The required signer families with no verified signature among + ``algorithms``.""" + return [name for name, algs in REQUIRED_FAMILIES.items() if not algs & set(algorithms)] + def trusted_keys_from_b64(*keys: str | None) -> dict[str, str]: """{kid hex: base64url key} from standard-base64 raw public keys. The COSE @@ -63,6 +79,7 @@ def check(envelope: bytes, trusted_keys: dict[str, str]) -> dict[str, Any]: "issuer": manifest["issuer"], "signatures_verified": bool(cose.verified), "signers": len(cose.signatures), + "algorithms": sorted(s.algorithm for s in cose.signatures if s.verified), "profile": manifest.get("profile"), "unbound_artifacts": manifest.get("unbound_artifacts", []), } @@ -73,6 +90,8 @@ def check(envelope: bytes, trusted_keys: dict[str, str]) -> dict[str, Any]: report["catalog_hash_matches"] = root == tm["catalog_hash"] result = verify_manifest(envelope, VerificationContext(trusted_keys=trusted_keys), RevocationStore()) report["verdict"] = result.result.value + report["missing_signers"] = missing_families(report["algorithms"]) + report["hybrid"] = report["signatures_verified"] and not report["missing_signers"] return report @@ -95,7 +114,7 @@ def main(argv: list[str] | None = None) -> int: print(json.dumps({"error": type(e).__name__, "detail": str(e)}, indent=2)) return 1 print(json.dumps(report, indent=2)) - ok = report["signatures_verified"] and report.get("catalog_hash_matches", True) \ + ok = report["hybrid"] and report.get("catalog_hash_matches", True) \ and OverallResult(report["verdict"]) in ACCEPTED return 0 if ok else 1 diff --git a/integrations/provenza-agent-manifest/tests/test_verify_provenza_manifest.py b/integrations/provenza-agent-manifest/tests/test_verify_provenza_manifest.py index a83fb28..bb9a415 100644 --- a/integrations/provenza-agent-manifest/tests/test_verify_provenza_manifest.py +++ b/integrations/provenza-agent-manifest/tests/test_verify_provenza_manifest.py @@ -5,8 +5,10 @@ import json import sys +from datetime import datetime, timezone from pathlib import Path +import agent_manifest._verify as sdk_verify import cbor2 import pytest @@ -19,20 +21,75 @@ ENVELOPE = (HERE / "agent-1-manifest.cose").read_bytes() KEYS = json.loads((HERE / "trusted_keys.json").read_text()) +# The sample's validity window: issued 2026-11-02T05:12:23Z, expires 2027-01-31T05:12:23Z. +WITHIN = datetime(2026, 12, 1, tzinfo=timezone.utc) +AFTER = datetime(2027, 2, 1, tzinfo=timezone.utc) + +ED25519, ML_DSA_65 = -19, -49 + + +@pytest.fixture +def clock(monkeypatch): + """Fix the time the SDK's verification engine checks the validity window + against, so a test does not change meaning when the sample expires.""" + + def set_now(now: datetime) -> None: + class Frozen(datetime): + @classmethod + def now(cls, tz=None): + return now if tz is None else now.astimezone(tz) + + monkeypatch.setattr(sdk_verify, "datetime", Frozen) + + return set_now + + +def _keeping(algorithm: int) -> bytes: + """The sample envelope with every signer but ``algorithm`` removed. The + remaining signature is untouched and still verifies on its own: COSE_Sign + signers do not cover each other.""" + tag = cbor2.loads(ENVELOPE) + protected, unprotected, payload, signatures = tag.value + kept = [s for s in signatures if cbor2.loads(s[0])[1] == algorithm] + assert len(kept) == 1 + return cbor2.dumps(cbor2.CBORTag(tag.tag, [protected, unprotected, payload, kept]), canonical=True) + + +def _run(tmp_path, envelope: bytes, capsys) -> tuple[int, dict]: + path = tmp_path / "manifest.cose" + path.write_bytes(envelope) + keys = tmp_path / "keys.json" + keys.write_text(json.dumps(KEYS)) + code = v.main([str(path), "--keys", str(keys)]) + return code, json.loads(capsys.readouterr().out) + def test_signatures_catalog_and_profile(): report = v.check(ENVELOPE, KEYS) - assert report["signatures_verified"] and report["signers"] == 2 # Ed25519 + ML-DSA-65 + assert report["signatures_verified"] and report["signers"] == 2 + assert report["algorithms"] == [ML_DSA_65, ED25519] and report["hybrid"] is True + assert report["missing_signers"] == [] assert report["catalog_hash_matches"] and report["tools"] == ["files.read"] assert report["profile"] == "composition-only" assert "system_prompt" in report["unbound_artifacts"] and "policy_bundle" in report["unbound_artifacts"] -def test_verdict_is_incomplete_not_valid(): - # a composition-only manifest authenticates; it never claims to be complete. - # (After expires_at the engine reports the expiry instead.) - report = v.check(ENVELOPE, KEYS) - assert report["verdict"] in ("INCOMPLETE", "EXPIRED") +def test_verdict_is_incomplete_not_valid(clock): + # a composition-only manifest authenticates; it never claims to be complete + clock(WITHIN) + assert v.check(ENVELOPE, KEYS)["verdict"] == "INCOMPLETE" + + +@pytest.mark.parametrize("kept, missing", [(ED25519, "ml-dsa-65"), (ML_DSA_65, "ed25519")]) +def test_one_signer_is_not_the_hybrid_signature(tmp_path, capsys, clock, kept, missing): + # The remaining signature verifies against the supplied key and the verdict + # is unchanged; the envelope is still refused, whichever half is left. + clock(WITHIN) + code, report = _run(tmp_path, _keeping(kept), capsys) + assert report["signatures_verified"] is True and report["signers"] == 1 + assert report["algorithms"] == [kept] and report["verdict"] == "INCOMPLETE" + assert report["hybrid"] is False and report["missing_signers"] == [missing] + assert code == 1 def test_a_changed_payload_fails(): @@ -49,9 +106,15 @@ def test_unknown_keys_fail(): v.check(ENVELOPE, {"00" * 32: "AAAA"}) -def test_cli_exit_codes(tmp_path, capsys): - keys = tmp_path / "keys.json" - keys.write_text(json.dumps(KEYS)) - assert v.main([str(HERE / "agent-1-manifest.cose"), "--keys", str(keys)]) in (0, 1) - out = json.loads(capsys.readouterr().out) - assert out["signatures_verified"] is True +def test_cli_succeeds_within_the_validity_window(tmp_path, capsys, clock): + clock(WITHIN) + code, report = _run(tmp_path, ENVELOPE, capsys) + assert report["hybrid"] is True and report["verdict"] == "INCOMPLETE" + assert code == 0 + + +def test_cli_fails_once_expired(tmp_path, capsys, clock): + clock(AFTER) + code, report = _run(tmp_path, ENVELOPE, capsys) + assert report["hybrid"] is True and report["verdict"] == "EXPIRED" + assert code == 1