Skip to main content

provellm-verify

Two packages, one job: let somebody who is not ProveLLM decide whether to serve an AI agent that is calling them.

package install
Python provellm-verify pip install provellm-verify
TypeScript @provellm/verify npm i @provellm/verify

Why this exists

Everything else ProveLLM signs proves what an agent did, and is worth something to the customer running it. A portable credential states what an agent may do, and is worth something to everyone that customer does business with — which is the difference between a feature and a protocol.

That only works if the other side can check one, and until now they could not. The verifier existed twice in this repo: a CLI an auditor runs by hand (api/scripts/verify_receipt.py) and the browser verifier on the public verify page (web/src/lib/receiptVerify.ts). Neither is installable, and neither answers the question a partner actually has, which is not "is this receipt genuine" but "should I serve this request".

The honest sentence

A credential proves the agent was authorized when it was issued and has not expired. It is not a statement that the agent is authorized right now.

That is not a caveat bolted on: it is Verdict.guarantee, returned with every verdict, so the sentence cannot get lost between this README and yours. If you need better, pass check_revocation=True / checkRevocation: true and accept that you have just made your API depend on ours being reachable — which is the thing verifying offline exists to avoid, so the SDK keeps serving the offline verdict when it is not, and tells you that is what happened.

Python

from fastapi import Depends, FastAPI
from provellm_verify import InMemoryReplayCache
from provellm_verify.fastapi import ProveLLMCredential, require_credential

app = FastAPI()

agent = require_credential(
    org_id="org_abc123",          # the customer whose agents you serve
    audience="acme-refunds",      # the identifier THEY used for you
    leeway_seconds=5,             # clock skew you will forgive, both directions
    replay_cache=InMemoryReplayCache(),   # makes a credential single-use — read below
)

@app.post("/refunds")
def create_refund(caller: ProveLLMCredential = Depends(agent)):
    if not caller.allows(action="issue_refund"):
        return {"error": "this agent's passport does not cover refunds"}
    return {"ok": True, "agent": caller.agent}

Verifying without a web framework:

from provellm_verify import Issuer, verify

verdict = verify(token, issuer=Issuer("org_abc123"), audience="acme-refunds")
if not verdict.valid:
    print(verdict.code, verdict.reason)

TypeScript

import express from "express";
import { InMemoryReplayCache } from "@provellm/verify";
import { requireProveLLM } from "@provellm/verify/express";

const app = express();

app.post(
  "/refunds",
  requireProveLLM({
    orgId: "org_abc123",
    audience: "acme-refunds",
    leewaySeconds: 5,
    replayCache: new InMemoryReplayCache(),
  }),
  (req, res) => {
    if (!req.provellm!.allows({ action: "issue_refund" })) {
      return res.status(403).json({ error: "not covered by this agent's passport" });
    }
    res.json({ ok: true, agent: req.provellm!.agent });
  },
);

Watching the transparency log (1.1.0)

A credential or a receipt tells you about one call. The transparency log is the claim that the record of every call is append-only — and until 1.1.0 neither package could check it, so that claim rested on ProveLLM's word.

The shape of the check is: pin a head once, somewhere ProveLLM cannot reach, and later demand a proof that the head you pinned is still sitting inside the current one, unchanged.

import json, urllib.request
from provellm_verify import verify_consistency_proof, verify_tree_head

get = lambda p: json.load(urllib.request.urlopen("https://api.provellm.com" + p))

pinned = json.load(open("pinned_head.json"))     # saved on an earlier run
head = get("/transparency/head")
proof = get(f"/transparency/consistency?first={pinned['tree_size']}&second={head['tree_size']}")

assert proof["first_root"] == pinned["root_hash"], "the log rewrote its own history"
assert verify_consistency_proof(
    proof["first"], proof["first_root"],
    proof["second"], proof["second_root"], proof["proof"]), "proof does not fold"
assert verify_tree_head(head, get("/transparency/log-key")["keys"]), "head not signed by the log"

json.dump(head, open("pinned_head.json", "w"))   # only after it verified
import { verifyConsistencyProof, verifyTreeHead } from "@provellm/verify";

const ok = await verifyConsistencyProof(
  proof.first, proof.first_root, proof.second, proof.second_root, proof.proof);

Three things about this that are easy to get wrong:

  • The pinned root is the load-bearing line. Without that first assertion the proof shows only that ProveLLM served two roots consistent with each other, which a log rebuilt overnight also does. Move the pin forward only after the old one verified.
  • Both sizes travel with the proof, and neither is optional. This tree does not commit to its own size — [A,B,C] and [A,B,C,C] hash identically — so a root compared without its size is a statement about two different logs.
  • It is not RFC 6962. The construction is duplicate-last (bitcoin-style). A Certificate Transparency verifier pointed at this log will reject intact proofs; that is the CT verifier being wrong about which tree it is reading.

verify_tree_head returns False for a head with no log_signature. That is "nobody signed this with a key you can check", not "forged": heads sealed before the log key existed carry only an internal HMAC. A monitor that exits non-zero on that will alarm on ProveLLM's own history and teach its owner to ignore the mail.

Replay checking makes a credential single-use

This is the one option that can break a working integration, so it is off by default and worth a paragraph.

The credential carries a nonce and a unique credential_id, and nothing anywhere rejected a second presentation of the same one — because only the party being presented to can know it has seen it before. Turning the cache on closes that. It also means the holder must mint one credential per call, which is what a 300-second TTL is for, but is not something the credential itself says. If the holder mints one and presents it on twenty requests, this refuses nineteen and everybody reads that as our bug.

The in-memory cache is single-process: two instances behind a load balancer do not share it. The ReplayCache interface is small enough that Redis SET key value NX EX ttl is a faithful implementation.

What it will not do

  • Tell you the agent is authorized now. See above. The TTL bounds the gap; the revocation endpoint closes it at the cost of a network call.
  • Verify an hmac-sha256 receipt. Those need the customer's own API key, so they are private by design rather than portable. You are told which algorithm you are holding instead of being told "forged".
  • Trust a receipt's own embedded key. A receipt handed over by an untrusted party can pair tampered content with the attacker's keypair and verify against the key it carries. Pass a key fetched from /api/orgs/{org_id}/pubkey, and match against the keys list — a receipt older than the org's last rotation matches a retired key, not the active one.
  • Read a chain_id as provenance. An agent names its own chain. When delegation_grant_id is absent, chain_self_asserted is true and the lineage is the holder's own word.
  • Prove a log is complete. A consistency proof shows nothing was rewritten or removed between two heads you have seen. It cannot show that everything which happened was written down in the first place — no transparency log can, and the Bitcoin anchors are what bound how far back the published history can move.

Releasing

Both packages carry the same version, and python3 sdk/build.py --check fails if they drift — it reads all four places the version is written (the Python __version__, python/pyproject.toml, the TypeScript source, and ts/package.json) and refuses if any disagree. CI runs it as its own gate. Two verifiers of the same signature that disagree about which version they are is the first thing that makes a bug report unanswerable.

Metadata

Release files for provellm-verify 1.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for provellm-verify 1.2.0
File Size Uploaded
provellm_verify-1.2.0.tar.gz 34.0 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for provellm-verify 1.2.0
File Interpreter ABI Platform
provellm_verify-1.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 66.9 kB

Release files / provellm_verify-1.2.0.tar.gz

Download URL provellm_verify-1.2.0.tar.gz
Size 34.0 kB
Tags Source
SHA-256 checksum
How to use checksums
37996ac81e5923fd5d351294fd0c2b34fd4ffe35bcba1b0b91e71a8965bb682e
BLAKE2b-256 checksum
How to use checksums
1cdc552c02d96fc5a6ef3358c88bac55ad8c14cd2d8083cc14bcb0f676deb69f
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.15

Release files / provellm_verify-1.2.0-py3-none-any.whl

Download URL provellm_verify-1.2.0-py3-none-any.whl
Size 33.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
fa116a985342aa2e45a2bff09ed66fe926c035470615ef8ed6b2a360dd447e01
BLAKE2b-256 checksum
How to use checksums
cc01d4d6daf736e7d42573342079a4b8b778532e0b64751bc47661b15f239c2b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.11.15

Release history Release notifications | RSS feed

This release

1.2.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page