Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/workflows/provenza-agent-manifest-conformance.yml
Original file line number Diff line number Diff line change
@@ -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 # v7.0.1
- 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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
56 changes: 56 additions & 0 deletions integrations/provenza-agent-manifest/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# 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://<server>/org/<org>/agent/<id>` |
| `issuer` | `spiffe://<server>/org/<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 <base64> --ml-dsa <base64>
```

`--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 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

```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`, `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.

## Source and licence

Provenza and this directory are Apache-2.0. The issuer is `backend/app/services/agent_manifest.py` in the Provenza repository.
18 changes: 18 additions & 0 deletions integrations/provenza-agent-manifest/integration.yaml
Original file line number Diff line number Diff line change
@@ -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"
123 changes: 123 additions & 0 deletions integrations/provenza-agent-manifest/src/verify_provenza_manifest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
"""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),
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
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 both signatures verify, the catalog root matches 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)

# 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
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),
"algorithms": sorted(s.algorithm for s in cose.signatures if s.verified),
"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
report["missing_signers"] = missing_families(report["algorithms"])
report["hybrid"] = report["signatures_verified"] and not report["missing_signers"]
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["hybrid"] 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())
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""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 datetime import datetime, timezone
from pathlib import Path

import agent_manifest._verify as sdk_verify
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())

# 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
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(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():
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_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
Loading
Loading