PCM-protocol

mcp
Security Audit
Pass
Health Pass
  • License — License: MIT
  • Description — Repository has a description
  • Active repo — Last push 0 days ago
  • Community trust — 37 GitHub stars
Code Pass
  • Code scan — Scanned 12 files during light audit, no dangerous patterns found
Permissions Pass
  • Permissions — No dangerous permissions requested

No AI report is available for this listing yet.

SUMMARY

Proof-Carrying Messages: a protocol for AI agents whose claims carry evidence the receiver checks offline

README.md

Proof-Carrying Messages (PCM): a protocol for AI agents

PCM-detected lies
benchmark
vectors
verifier
spec
licence

PCM is a protocol for messages between AI agents in which each claim about what happened can carry evidence that the
receiver checks offline: records from a log the sending agent can't write, with Merkle inclusion proofs.

PCM 0.1. Numbers in parentheses below are sections of the specification, SPEC.md.

In one paragraph. When one AI agent tells another "the tests passed" or "a person approved this", the receiver
today has only its word. PCM lets each such claim carry signed records from a log the sending agent can't write, and
the receiver checks them offline. In a pre-registered benchmark of 926 messages, 712 of them lies, PCM accepted 10
lies, all of one kind it states as a limit (stale evidence); plain A2A accepted all 712, and a check of the sender's
signature alone accepted 643. Models of six real agent protocols, added after the pre-registration, accepted from 504
(AP2 mandates) to 712 (A2A with a signed Agent Card). Checking a message takes about 1 ms (Evaluation).

Lies a receiver accepts, of 712: plain A2A 712, a sender-signature check 643; models of real protocols: A2A 1.0 with a signed card 712, uAgents 702, RFC 9421 with replay checks 682, SCITT receipts 550, Verifiable Intent 509, AP2 mandates 504; PCM 0.1 10, all stale evidence

Try it (with Tracekit, the reference implementation):

pip install tracekit-ai==1.5.0
tracekit pcm demo        # two agents, a refund request and a real signer: accepted, unproven and rejected messages

The problem

Agents pass claims to each other that nobody can check. Agent A asks agent B to refund $120 on order 81, and says:

  • "alice approved refunds up to $200 for this order";
  • "the refund tests passed";
  • "I fetched the order from api.example.com".

B has only A's word for each. If A was prompt-injected, or another process sends the message in A's name, B pays out
anyway. If A says the tests passed, B can't tell whether a test ran, whether it passed, or whether A wrote the words
itself. Agent protocols such as A2A and MCP carry the message and can authenticate the connection, but they don't
define how B checks, offline and without trusting A, what the message says happened.

The idea

In three steps:

  1. The agent's tool calls go through an attesting signer (Tracekit, for example), which records each call in an
    append-only Merkle log the agent can't write.
  2. When the agent sends a message, the signer signs it, and the message carries the log records that back each
    claim, with inclusion proofs.
  3. The receiver checks the signature, the proofs and the records offline, against keys it pins, and gets proven,
    unproven or failed for each claim.
How a PCM claim travels: agent A acts through an attesting signer that records and signs every event; the message carries records and inclusion proofs; agent B checks them offline

In detail, a PCM message is a JSON envelope signed by an attesting signer: a service that holds the keys, records the run's
events in an append-only Merkle log, and signs only as the identity of the run that calls it. The agent never holds a
key (4.4). The message carries a typed intent, checked against a schema document both sides pin by its hash (5);
claims, each of which can carry evidence, records of the sender's own run with RFC 9162 inclusion proofs under
a checkpoint of the log whose key signed the message (6); and grants, signed statements rooted in a person's
approval of what the sender may ask for (7). The receiver checks all of it offline against the keys it pins. Each
claim gets one of three results, proven, unproven or failed, and the message one of three verdicts, accept,
accept-unproven or reject (8). Text that does not parse strictly gets the fourth, bad-input, and is refused
before any check (3.3, 13.2). A claim that rests on the agent's word alone is unproven, never proven.

What is new

  1. Claims are proven from records the agent can't write. A record proves a claim only when a party other than
    the agent wrote it: the signer (from a person's answer or an observer's report) or an attesting gateway or
    executor that ran the call (T2). A result the agent's own process reported (T1) gives unproven, and the T2
    mark is read from fields the signer sets, never from data the agent sent (6.4, 6.5). A decision record holds an
    HMAC commitment to the call's args, and the sender opens only the decisions it chooses to show (6.3, 15).

  2. "Unproven" is kept apart from "failed", and doubt is never proof. failed: the claim is malformed, its
    evidence does not verify, or no record in it says what the claim says. unproven: no evidence, evidence of only
    the agent's word or of less than the claim (an attempt to connect, not a connection), or a record the verifier
    can't match with certainty (6.1). A Level 1 verifier, which skips evidence, never gives proven (13.1). A result
    names the checks its caller skipped, and a receiver must not act on such a result (8).
    How a verifier judges one claim (6.1):

    How a verifier judges one claim: no evidence gives unproven, evidence that does not verify gives failed, the agent's own report gives unproven, records that say the claim give proven
  3. Claims bind to what was done, not to names the agent chose. An agent names its own calls, so an approval of
    call refund-81-approved says nothing of what the call was. approved_by is proven only with the approval, the
    request it answers and the decision that request names, and the result carries approved: the tool and args the
    person approved, as a field every verifier must return alike. With covers: true the verifier also checks that
    the approval stands for this request, by one of two paths: it issued the message's root grant, or it is of the
    tool the intent's schema names, with args equal in JCS to the intent's. A schema that lists approved_by is met
    only by such a claim (6.6, 8, 14.27).

  4. Authority comes from a person and only narrows. A root grant exists only after a person the signer's
    configuration names approved a pcm.grant call with exactly the grant's terms: not an agent approver, not
    self-approved, not the grant's subject (7.4). A child only narrows its parent, by a fixed implication table over
    typed caveats (7.3). No agent holds a key: the signer signs each child after checking it (7.5), and the verifier
    checks the chain again, bound to the sender's tenant and to the log that signed the message (7.6, 14.18).
    A chain of grants (7.3–7.6):

    A grant chain only narrows: alice approves, a root grant is issued, a narrower child is signed and a wider child is rejected
  5. Replay rules that survive a restart in bounded memory. A sender is the pair (public key, agent). A receiver
    refuses a message outside its clock window, signed before the receiver started (so it restarts with no saved
    state), with a nonce it has seen, or at or below the sender's floor, which eviction raises. A nonce is spent only
    after the recipient and schema checks pass; a receiver identity accepts a message at most once, across replicas (9).

  6. A closed vocabulary, with its data frozen in the protocol. Four claim types, each with exact proven,
    unproven and failed rules over one named evidence profile; a new type needs a checker and vectors for each
    result (6.6, 17.2). The table that places tool names in classes (54 names, frozen by its SHA-256) and the list of
    test runners are protocol data, so no sender or operator can change how a call is classed (6.8, 10.2, 14.29).

  7. One verdict whatever the verifier's language, library or Unicode version. Where implementations disagree,
    PCM picks the strict reading every verifier can apply alike. Hosts are compared in printable ASCII only (IDNA 2003
    and browsers map faß.example differently); an IPv4 host counts only as a dotted quad (inet_aton reads
    0127.0.0.1 as 87.0.0.1, the macOS resolver as 127.0.0.1); a URL needs a scheme or a leading //; base64 must
    be canonical; and 1e400 is bad input, not infinity. A claim outside these rules is unproven, and the result
    tells the sender how to send it (3.3, 4.3, 6.7, 6.8).

Section 14 lists 29 attacks and mistakes found while building and reviewing the reference implementation, each with
the rule that closes it and, for most, the vectors that pin it.

A worked example

The refund above, from vectors/accept-approved-by.json, which the reference
implementation's signer produced. A (uid:990101) asks B (uid:990102) for refund.request with args
{"amount": 12000, "order": 81}, the intent named by the SHA-256 of its schema document's JCS (5.4). The schema
lists approved_by and names no tool. The message carries alice's root grant (7.1), amount <= 20000 and
order == 81 for one hour, not delegable, and one claim, approved_by alice of call grant-1 with covers: true:
the sender says her approval covers this request. The signer set the message's id, ts, nonce and sig (4.4).
The blocks below are examples SPEC.md ties to this vector (6.6, 8), quoted exactly: the comment before each in this
file's source names the vector value it shows, and ... marks what is left out.

The claim and its evidence. Three records of A's run, the decision, the request and alice's approval (shown in
6.4), each with a four-hash inclusion proof under a checkpoint of tree size 9.

{"type": "approved_by", "args": {"person": "alice", "call": "grant-1", "covers": true},
 "evidence": {
  "checkpoint": "example.com/pcm-test-log\n9\nQhInJS9+Rjyar8mhP1wGwzx1O2DYcMy8af/XYRCqSb8=\n\n— example.com/pcm-test-log C7keZoQnlibbpH9yOCeEzKJs6Hzc97YYaP1hsfTBxyO0QzEKJRpaXpxBKoLJEIi9d0FMYRNNmWOSl0cOBOf2whMhowQ=\n",
  "records": [
   {"event": {"type": "policy.decision", "seq": 3, "...": "...",
              "data": {"tool_use_id": "grant-1", "tool": "pcm.grant", "decision": "ask",
                       "decision_id": "dec-76e1373f66cddad74ba54eb2ffcf97af", "...": "..."}},
    "hash": "sha256:444ea34333ed7e77be0fe1ca8f57b108b93d79258dd27ec3f2ffe0f5d89409f5",
    "proof": ["GHc2iRz78zZ9UrFNQfRdYDhZyd9ere1mVrgDE2zN4w8=", "a1WP4EFPP5lJvKXGna0/aMjExMY6buoajX2ZZWWR87w=",
              "XV3DvLmIxeoAAS29RasT6tRFgGOFhLjjSzUyQPdNyZs=", "oGluwXsyMyIJMoCTujrGPPsyxEcnsqfn+A06KUVWSuc="]},
   {"event": {"type": "approval.request", "seq": 4, "...": "...",
              "data": {"approval_id": "apr-afa8cc12fcd9032a7eb9c9479deedd68",
                       "decision_id": "dec-76e1373f66cddad74ba54eb2ffcf97af", "...": "..."}},
    "hash": "sha256:39c29ce4973ac0753b77f3ae1a7056a8d06d054fe6b7ea951c423f5b5491288a",
    "proof": ["fc2FAMAMEmTGeNOEggj5AuC4lnCiG6ZqObEWzUcNHsU=", "8j9RAMAAem3ddLt8BiZLIR4p1Hel7+3/XhWay/9A3Gg=",
              "EbdsQQodmPPv9Jd89YbsSA1vYYjly5d/Q+Y6uHghTdc=", "oGluwXsyMyIJMoCTujrGPPsyxEcnsqfn+A06KUVWSuc="]},
   {"event": {"...": "the approval record of 6.4"},
    "hash": "sha256:3483c8f824e7072e479f93de2a5d9ce2f4e32ed6fc5472e8621a353da9371e1b",
    "proof": ["ePM3fNgfQR2STyNfBlxMZY4mugWmhJwjaNTjT420NBM=", "8j9RAMAAem3ddLt8BiZLIR4p1Hel7+3/XhWay/9A3Gg=",
              "EbdsQQodmPPv9Jd89YbsSA1vYYjly5d/Q+Y6uHghTdc=", "oGluwXsyMyIJMoCTujrGPPsyxEcnsqfn+A06KUVWSuc="]}]}}

The verifier checks that the pinned key of the log that signed the message signed the note; that each hash is the
hash of its event, whose proof leads to the note's root; and that each record is a valid event of the sender's run
and tenant (6.2). It links the approval (seq 5) to its request and the request to the decision by their ids (6.6).
Since the claim says covers and the decision is of pcm.grant, it then takes the grant path: the root grant's
issuer.approval_id, run and tenant must be those of this approval (6.6).

The result, for receiver uid:990102 with a fresh replay state:

{"signature": {"ok": true, "key": "example.com/pcm-test-log+0bb91e66"},
 "replay": {"ok": true, "detail": "fresh"},
 "schema": {"ok": true, "ref": "refund.request@sha256:d8636b6fbbd07430d057ea9ead9c27f0df1b918b10d34ab9257a4971d487846c", "errors": []},
 "grants": {"result": "ok", "steps": 1, "root": {"kind": "person", "id": "uid:990001", "person": "alice"}, "problems": []},
 "claims": [{"type": "approved_by", "result": "proven",
             "detail": "approval seq 5 by alice of \"pcm.grant\" call grant-1, which issued this message's root grant",
             "approved": {"tool": "pcm.grant", "args": null}}],
 "verdict": "accept", "reasons": [], "skipped": []}

approved says what alice approved: the pcm.grant call that asked for the grant this message carries, its args
not opened. The detail adds that this approval issued the message's root grant, which is how it covers the request.
The schema lists approved_by, so without covers the same proven claim would leave the verdict accept-unproven
(8). Related vectors show the other outcomes:

Vector What differs Verdict and reason
accept-unproven-t1-result adds tests_passed, whose only result is the agent's own report accept-unproven: the result is the agent's own report (T1)
accept-tests-passed-t2 the executor that ran the tests recorded the result accept: call call-9 ran 'pytest -q tests/test_refund.py', run by mtls:spiffe://example/executor (T2)
reject-claim-pytest-or-true the executor ran pytest -q tests/test_refund.py || true reject: … is not a single test command
reject-claim-changed-record-hash one hex digit of a record's hash changed reject: record 1: hash does not match the event
reject-claim-approved-by-self-approval erin approves a call of her own agent's run reject: no approval of grant-erin by the person erin
reject-grant-amount-over-caveat amount 50000 reject: grant 0: amount 50000 breaks amount <= 20000
reject-claim-approved-by-covers-other-args a refund.pay of 12000, whose schema names the tool pay, citing with covers alice's opened approval of a pay of 500 reject: … not this request's args {"amount":12000,"order":81}, so it does not cover this request
accept-unproven-approved-by-required-without-covers the same, without covers accept-unproven: approved_by: required, not proven to cover this request: claim it with covers true
accept-unproven-reached-host-idn reached_host xn--bcher-kva.example by a fetch of http://bücher.example/ accept-unproven: … its URL is not printable ASCII: send it without spaces or control characters, with the host in xn-- form and anything else percent-encoded
accept-unproven-reached-host-percent reached_host api.example.com by a fetch of http://%61pi.example.com/ accept-unproven: … its host has a %: send the URL with the host as plain ASCII, not percent-encoded
accept-unproven-reached-host-relative-path reached_host api.example.com by a fetch of /api.example.com/v1/refunds accept-unproven: … its URL has no scheme, so a client may read it against a base URL: send the whole URL, scheme and host

What a receiver does

  • Pass its own identity as me and keep one replay state for its life (9). Replay state is per receiver process:
    replicas that answer for one identity route each sender to one replica or share the state, since an identity
    accepts each message at most once. A result whose skipped is not empty is for audit only (8).
  • Never act on reject; treat what is unproven as the sender's word (8).
  • Where a person's approval is the authority, list approved_by in the schema, so that only a claim with
    covers: true meets it (6.6). An approval is of a call, not of a message: on the tool-and-args path it covers every
    message that asks for that tool with those args, to any receiver, as often as the sender sends one. For one-time
    authority, list a field unique to the request (such as request_id) in the schema and act once per value, or
    require a grant, whose caveats and expiry bound it (14.27).
  • For an approved_by without covers, read approved (its tool and args), not only result: the call id in
    the claim is the agent's own name, and approved is what the person saw (6.6).
  • A proven tests_passed proves only that this exact command line exited 0 through an attesting executor. Runners take
    flags that run no tests, so compare suite with the suites you accept (6.6).
  • A proven claim says that a call happened, not that nothing changed since. Check the age of the evidence, which both
    CLIs print after a proven tests_passed or file_written, and for a file, its digest against the bytes you hold
    (14.13).
  • Check grants.root against the people you accept: the verifier reports who the root person is, not whether to
    trust them (7.6).

Evaluation

The lie benchmark is a pre-registered test of false claims between agents, in bench/lies/; its full
results are in bench/lies/RESULTS.md. It asks how often a receiving agent accepts a false claim
from a sending agent. The design, PREREGISTRATION.md, was written before the run,
but it has no public timestamp before the results: it was a local note, committed with them, under a header that says
where the run departed from it.

Threat model. The sender controls its own process, its messages, its T1 reports and any file it can write, but
not the signer, its keys, its log or T2 results, and the receiver has only the message and its trust.json.

Data. Every message comes from real runs of a Tracekit signer, with T2 calls through the executor's custody
code: pytest suites in a temporary project, HTTP fetches to a local server, file writes, approvals answered by
people and by an agent approver, root grants and delegations. Each message is labelled from what happened, never by
a verifier. The lies fall in 16 classes (L1–L16), from fabricated events, edited records and records of another run
or log to stale proofs, replays, widened or expired grants and encodings; most are edits a sender can make to its own
message before the signer signs it. Each message is checked by Tracekit's pcm.verify, by the standalone
pcm_verify, and by two baselines: plain A2A, which accepts every message, and a check of the sender's signature
only. After the pre-registration, models of six real agent protocols were added as receivers (below).

Hypotheses (pre-registered 2026-10-28, with their pass bars):

  • H1: no lie is accepted (0 false accepts).
  • H2: at least 99% of honest claims backed by T2 or the signer are proven.
  • H3: every claim backed only by T1 is unproven.
  • H4: the two verifiers give the same verdict and reason class on every message.

The baselines and the cost (message size, p50 and p99 verify time) are reported without a pass bar.

Results, on 926 messages, pinned to Tracekit 1.5.0 (its release head fbcd489089, Cygnux-Labs/Tracekit#466). Dataset 2 was first produced at Tracekit
7d843e6377 and this repository at 73a287c (main 0f26c38 merged, with PCM's fixes for F1, F3 and F5); its
verdicts reproduce unchanged on Tracekit v1.5.0:

result
H1 lies accepted, each verifier fail, 10 of 712 (1.4%, 95% CI 0.8–2.6%), all stale evidence
PCM's verdicts on the 712 lies 626 rejected, 76 accept-unproven (the claim is flagged as not proven), 10 accepted
H2 honest claims proven fail, 81 of 83 honest messages (76 of 78 claims, 97.4%), both misses the 404 limit
H3 T1-only claims unproven pass, 72 of 72
H4 the two verifiers agree pass, 926 of 926
plain A2A, lies accepted 712 of 712
sender's signature only, lies accepted 643 of 712 (90.3%)
six real protocols as receivers (added later), lies accepted 504 to 712 of 712 (below)
verify time p50 (p99), Tracekit / standalone 1.2 (2.4) / 0.8 (1.8) ms

The benchmark earned its keep before these numbers: an earlier run found three bugs (F1, a leading-zero IPv4 host;
F3, an approval of a different call; F5, a number read two ways), each fixed in the spec and both verifiers with
vectors that pin it. That run is in RESULTS.md.

What is left:

  • H1: the 10 false accepts are all stale evidence (L10.T.06–10, L10.F.06–10). A suite passed, the code
    changed and the suite failed, and the sender cites the pass; or a file was written twice, and the sender cites the
    first write. Both claims are proven, since a 0.1 claim says that a call happened, not that nothing happened
    since. This is a documented 0.1 limit (14.13), and Appendix B question 5 designs evidence of the run since the call
    for a later draft. Every other lie, 702 of 712, is not accepted, in every scope: claim, evidence, authority,
    delivery, encoding and message.
  • H2: 81 of 83 honest messages (76 of 78 claims, 97.4%), because of the 404 limit. Two honest fetches were
    answered with a 404 (honest.H.15, .16). The host was reached, but the executor records a status of 400 or more
    as an error, and 0.1's reached_host needs a T2 result that is ok, so the claim is failed and the honest message
    is rejected. SPEC 6.6 states this limit, and Appendix B question 6 designs reached_host on any response for PCM
    0.2. As pre-registered, the pass bar is on claims; 13 of the 83 messages carry a grant and no claim. Of the 83, 69
    get accept: besides the two 404s, 12 cite an approved_by without covers under a schema that lists it: their
    claims are proven, but their verdict is accept-unproven, by design.
  • H3 and H4 pass. All 72 T1-only claims are unproven, and the two verifiers agree on all 926 messages,
    including each verifier's own strict loader for messages sent as raw text.
  • The baselines. Plain A2A accepts all 712 lies. The signature-only check accepts 643 (90.3%): it catches only
    forged or stripped signatures (44 of L6) and some encodings (25 of L16).

Real protocols as receivers (added after the pre-registration; no hypothesis covers them). Each is a model of what
one protocol lets a receiver check, built from its specification, not a run of its SDK
(protocols.py cites the sections). Where these protocols sign, they sign with the
sending agent's own key or its platform's, so a lying sender signs its lie; only AP2 and VI mandates and SCITT
receipts carry a signature the sender can't make.

receiver lies accepted honest accepted
A2A 1.0 + signed Agent Card 712 of 712 (100%) 83 of 83
uAgents envelope 702 (98.6%) 83
RFC 9421 + replay checks (as in ANP, Visa TAP, Web Bot Auth) 682 (95.8%) 83
SCITT receipts (RFC 9943, 9942) 550 (77.2%) 83
Mastercard Verifiable Intent 0.1 509 (71.5%) 67
AP2 0.2 mandates 504 (70.8%) 67
AP2, SCITT and RFC 9421 stacked: accepts only what all three accept 365 (51.3%) 67
  • The signed card catches nothing, and the envelope and the HTTP signature catch only replays (10 and 30 of 50).
    SCITT receipts catch evidence that isn't in a pinned log (162 lies), but no rule ties a claim to its records, so
    true records cited for false claims pass. AP2 and VI catch lies about grants and approvals, and none of the 450
    about tests, hosts and files, which carry no mandate.
  • AP2 and VI reject 8 honest delegations (AP2 0.2 has no agent-to-agent delegation) and 8 honest approvals whose
    approved values the dataset doesn't disclose. The same gaps in the dataset decide 63 approval lies the models
    reject (64 for VI); counted as accepted, AP2 and VI would accept at most 567 and 573.
    RESULTS.md has the per-class results and what couldn't be modelled faithfully.
  • AP2 is modelled with its Trusted Agent Provider model: the receiver trusts the agent's platform, here one whose log
    it pins, to sign a mandate only after a person consents. Verifiable Intent stands for AP2's other model, User
    Credential, with a person's key an issuer certifies. Signatures are real Ed25519 over the models' own byte layouts,
    not literal JWS, SD-JWT or COSE; no check depends on that syntax.
  • A receiver that runs the AP2, SCITT and RFC 9421 checks and accepts only what all three accept still accepts 365
    lies (51.3%).

Cost. Median size of the honest messages, by claim type, against the same messages as plain A2A would carry
them, 0.4 to 0.7 KB at p50. A size is the UTF-8 bytes of the message as compact JSON with sorted keys (1 KB = 1000
bytes). The plain A2A message is the same message without sig, grants and each claim's evidence, and "vs plain
A2A" is the median of the per-message ratios.

claims PCM p50 vs plain A2A
tests_passed 4.2 KB 9.2x
reached_host 4.2 KB 9.5x
file_written 4.4 KB 7.4x
approved_by 6.1 KB (p99 8.4 KB) 13.0x
a grant only 1.9 KB 4.5x
three claims 11.7 KB 16.2x

Each p99 is over 4 to 22 messages of one type, so it is the largest of them. The approved_by p99 comes from the
root-grant covers evidence, which carries four records. Verifying a message takes 1.2 ms at p50 (2.4 ms at p99) with
Tracekit's verifier and 0.8 ms (1.8 ms) with the standalone one, timing the verify call alone, over 876 messages; the
other 50 are raw texts the verifier's own loader refused, so no check ran.

Limits. One machine (macOS 26.3 arm64, Python 3.11.15), and the leading-zero IPv4 finding depended on the
platform. The signer and executor run in-process: the executor is Tracekit's custody code with an in-process client,
not the HTTP executor service. There is no real DNS: names are served by the local server. Dataset 2 was generated
once; dataset 1, generated three times, gave the same counts in every cell, apart from one relabelled message.

Re-run it. From this repository's root. To reproduce the published numbers from the committed dataset, run
run and then report, with a Tracekit checkout at v1.5.0 (run also needs the standalone verifier's rfc8785 and
cryptography). Every verdict and count reproduces; timings differ from run to run.

PYTHONPATH=<tracekit> python -m bench.lies run --verifier all   # results/{tracekit,standalone,baselines}.jsonl
python -m bench.lies report                                     # results/results.json, findings/, the tables

To make a new dataset, run gen first. It overwrites the committed data/dataset.jsonl and data/meta.json, and
needs Tracekit's dependencies plus pytest, IPv6 loopback (::1), and a resolver that reads IPv4 spellings as macOS
does: each host's label is what this machine's getaddrinfo reads, so on Linux some L16 labels differ.

PYTHONPATH=<tracekit> python -m bench.lies gen                  # data/dataset.jsonl, data/meta.json (~1 min)

The seed (20261028) fixes the keys and every choice; run ids, nonces, timestamps and signatures change each time the
dataset is generated.

Relation to prior work

  • Proof-carrying code (Necula and Lee, OSDI 1996; Necula, POPL 1997) gave the name and the shape: the producer
    ships a proof, and the consumer checks it with a small checker instead of trusting the producer. A PCM proof is a
    signed record with an inclusion proof, not a formal proof, so it holds only as far as its signer is trusted (14.25).
  • Certificate Transparency (RFC 6962, 2013; RFC 9162, 2021). PCM evidence uses its Merkle hashing and inclusion
    proofs, with checkpoints and witness cosignatures in the C2SP signed-note, tlog-checkpoint and tlog-cosignature
    formats. CT logs certificates for public audit; PCM hands a receiver the few records of a run that back a claim.
  • Capability tokens. Macaroons (Birgisson et al., NDSS 2014), Biscuit and UCAN let a holder attenuate a token
    itself, offline. PCM grants also only narrow, but no agent holds a key: the signer signs each step after checking
    it, a root exists only after a person's recorded approval, and caveats are typed by the intent's schema and
    compared by a fixed table rather than a general logic (Biscuit's policies are Datalog).
  • JCS (RFC 8785, 2020) is the canonical form PCM hashes and signs, with one tag for messages, another for grants (4.1, 4.2).
  • A2A (2025) and MCP (2024) carry messages between agents and tools. A PCM message is meant to travel inside
    them as a part or an embedded resource of type application/vnd.pcm+json (11, draft).
  • in-toto (Torres-Arias et al., USENIX Security 2019) and SLSA (v1.0, 2023) sign statements about software
    supply-chain steps. PCM's T2 rule is close to SLSA's view that provenance should come from the build platform, not
    the build's own steps; PCM applies it per claim in a message, with a third result for what can't be checked.
  • SCITT (RFC 9943, 2026, with COSE Receipts, RFC 9942) checks that a signed statement is intact and in a
    transparency log, by a receipt of its inclusion; individual drafts without IETF standing profile it for agent
    actions (draft-emirdag-scitt-ai-agent-execution, draft-noa-scitt-ai-agent-receipt). It has no rule that ties a
    claim to a record, no T1/T2 distinction, no replay rule and no grants; PCM adds those over the same kind of evidence.
  • AP2 (Agent Payments Protocol 0.2, 2026, now at the FIDO Alliance) checks a chain of SD-JWT mandates: who
    authorized a payment, under which constraints, until when, bound to the agent's key. It says nothing about what an
    agent did, and agent-to-agent delegation is outside its scope; PCM adds claims about actions, backed by records,
    and grant chains that only narrow.
  • Mastercard Verifiable Intent (0.1, a draft) adds a layer to AP2's model: a credential provider certifies the
    user's device key, and that key signs the mandates. Like AP2, it checks a purchase's authority; PCM adds what the
    agent did besides.
  • ANP (Agent Network Protocol 1.2, 2026) checks the sender: an RFC 9421 signature by its DID key, a time window
    and a (keyid, nonce) replay cache. Its spec says authentication alone does not imply business authority, and its
    authorization profile (ANP-05) is a draft. The sender signs with its own key, so a liar signs its lie; in PCM the
    attesting signer signs, and each claim carries records.
  • Notarized Agents (Figuera, arXiv 2606.04193, 2026): the called service signs a receipt of the action, encrypts
    it to the agent's owner and publishes it to a witness-cosigned Merkle log. PCM needs no service to cooperate: the
    sender's attesting signer and executor record the call, and any receiver checks the records offline.
  • NabaOS (Basu, "Tool Receipts, Not Zero-Knowledge Proofs", arXiv 2603.10060, 2026) cross-checks a model's claims
    against HMAC-signed tool-execution receipts. An HMAC is checked with the key that made it; PCM's records are signed
    with public keys any receiver checks offline.
  • South et al. ("Authenticated Delegation and Authorized AI Agents", arXiv 2501.09674, 2025) extend OAuth 2.0 and
    OpenID Connect with agent credentials and scoped delegation: who an agent acts for, and with what scope. PCM adds
    claims about actions, and grants rooted in a person's recorded approval that a receiver checks offline.
  • A2ABreak (Lotfi, Rahman, Karim and Bertino, arXiv 2609.10871, 2026) builds a 37-state machine of the A2A spec
    and finds 11 new vulnerabilities, among them context injection through unprotected context ids and identity lost
    across multi-hop delegation. PCM signs its whole message, and each delegation step is a signed grant naming
    its issuer and subject; parts of an A2A message outside the PCM part stay unsigned (11.1).

Status and scope

PCM 0.1 is experimental: every 0.x draft uses "pcm": 0 and may change incompatibly (16). Each part of the spec is
marked [Implemented] (the reference implementation does it and produced the vectors) or [Draft]. No transport
binding verifies PCM yet: the A2A, MCP and HTTP bindings (11) are drafts, and Tracekit's A2A gateway forwards a PCM
part like any other part, without checking it. The transfer-record extension for latent model-to-model transfers (12)
is a draft: Tracekit records a transfer (12.2), and nothing checks the transfer payload yet. The TypeScript verifier
is a plan, docs/ts-verifier-plan.md, not code.

What PCM does not prove.

  • That the agent is honest. A prompt-injected agent can still send anything its grants allow (1).
  • That the signer was right. A verifier checks that the signer signed, not that what it recorded is true. The signer
    and its keys are the trusted computing base: a leaked log key signs messages, grants and checkpoints at will, and
    PCM 0.1 has no revocation and no key validity period (14.17, 14.24, 14.25).
  • That something did not happen. The four claim types each say that something happened. The sender chooses which
    records to send, and the receiver learns nothing of records it was not sent (6.6, 15).
  • That a state is current. Tests that passed at 10:00 say nothing about code changed at 10:05 (14.13). These are the
    benchmark's only remaining false accepts.
  • What a T2 call ran beyond its tool name's class: an executor action named Bash counts as a shell call (14.26).

Future work (Appendix B): Unicode hosts under one pinned IDNA rule; a key for messages and grants only, and a way
to declare key rotation; a fixed regular-expression dialect for id patterns; a trust member that pins which
attesting parties may attest each tool class; fresh evidence, by evidence of the run since the call and by claims
bound to the tree or file a call ran on (question 5); and reached_host resting on any HTTP response, not only a
successful one, for PCM 0.2 (question 6).

Check a message

pcm-verify 0.1.0, in verifier/python, is a standalone Level 2 verifier (13.1): CPython 3.11.4
or later, depending only on rfc8785 and cryptography, with no Tracekit code.

pip install ./verifier/python
python -m pcm_verify MESSAGE --trust T --schema S [--me ID] [--strict] [--json]

MESSAGE is the message JSON and S the schema document of its intent. T is a trust.json (10.1) that holds one
Ed25519 key per log, each under its own log's name, {"logs": ["<vkey>"]}; for a Tracekit signer, tracekit signer trust --config <signer.yaml> -o pcm-trust.json writes one. --me is the receiver's identity, --strict requires a
proven claim of every type the schema lists, and --json prints the whole result. It exits 0 for accept, 3 for
accept-unproven, 1 for reject and 2 when it can't check: bad input (text that does not parse strictly, 3.3), a
configuration error (a schema or trust document it refuses, 5.2, 10.1) or a usage error. It keeps no replay state, so
it prints not checked: replay, and its result is for audit (9). It prints a proven approved_by's approved, and
after a proven tests_passed or file_written, how long before the message the oldest evidence record is, for the
receiver's age check (14.13); tracekit pcm verify prints the age too (C.3).

A verifier conforms on a vector when these fields equal the vector's expected (13.2): verdict, signature.ok,
replay.ok, schema.ok, grants.result, each claims[i].result, each claims[i].approved and skipped. The
detail, reasons and problems strings are for people and need not match. On a bad-input vector it conforms
when it refuses the text as a parse error before any check. The verifier's tests check it on every vector and on
every example SPEC.md and this README tie to one:

pip install ./verifier/python pytest
cd verifier/python && python -m pytest tests

Repository map

Path What
SPEC.md the PCM 0.1 draft specification
schemas/ JSON Schemas (draft 2020-12) of the message, grant, claim, evidence and schema document
registry/tools.json the frozen tool placement table, 54 tool names, SHA-256 15f8410f513344e4c60327d4fc3dd14949ebeaf7722f641d985d3ca721c8fe07 (10.2)
registry/tracekit.event.v2.json the event schema of the evidence profile tracekit.event.v2 (6.4)
vectors/ 187 vectors: 30 accept, 46 accept-unproven, 110 reject, 1 bad-input. Level 1 (no claim carries evidence): 49, of which 11 accept, 2 accept-unproven, 35 reject and 1 bad-input. Level 2: 138, of which 19 accept, 44 accept-unproven and 75 reject. Format: vectors/README.md
verifier/python pcm-verify 0.1.0, the standalone verifier, and its tests
tools/ gen_schemas.py and gen_vectors.py, which regenerate the schemas and vectors from a Tracekit checkout; gen_chart.py and gen_diagrams.py, which draw the README's chart and diagrams
docs/img/ the README's chart and diagrams, as light and dark SVGs
bench/lies/ the lie benchmark: PREREGISTRATION.md, the dataset generator, the runner, the six protocol receivers (protocols.py), the report and RESULTS.md
docs/ts-verifier-plan.md the plan for a TypeScript verifier

Reference implementation

Tracekit 1.5.0 (experimental) is the reference implementation, with signer
RPC contract 20. Its signer signs messages and grants (pcm_sign, pcm_grant, pcm_delegate) and hands out
evidence (pcm_evidence); its tracekit.pcm.verify gave the expected result of every vector; tracekit pcm demo
runs the refund example with a real signer. Appendix C maps each rule to Tracekit's code and lists where it does less
than the spec asks: replay state is per process (C.4). A consumed approval can also mint more than one root grant
until its pcm.grant call has a result, which the spec allows (7.4, 14.8). Where this repository and Tracekit
disagree, one of them has a bug: open an issue.

Citing

The paper is forthcoming. Placeholder entry, to be replaced when it is published:

@misc{pcm2026,
  title        = {{PCM}: Proof-Carrying Messages for {AI} Agents},
  author       = {{Cygnux Labs}},
  year         = {2026},
  howpublished = {\url{https://github.com/Cygnux-Labs/PCM-protocol}},
  note         = {Draft 0.1 specification. Placeholder: replace with the paper's entry}
}

Licence

MIT, LICENSE, copyright 2026 Cygnux Labs. It covers everything in this repository: the specification,
the schemas, the test vectors and the code.

Reviews (0)

No results found