Add Lesson 18: Securing AI Agents with Cryptographic Receipts (closes #503)#533
Conversation
Closes microsoft#503. Adds a complete lesson covering Ed25519-signed receipts for AI agent tool calls, with four hands-on exercises: 1. Sign and verify a receipt for a single tool call 2. Tamper with a receipt and observe verification fail 3. Build a hash-chained sequence of receipts 4. Wrap a tool function with automatic receipt signing The lesson is Python-first (matching the curriculum convention) and uses only widely-available primitives: PyNaCl for Ed25519, the jcs package for RFC 8785 canonical JSON, hashlib for SHA-256. The cryptographic logic is plain Python that students can read line by line. Production-tool references (protect-mcp, @veritasacta/verify, the agent-governance-toolkit Tutorial 33) are mentioned at the end as next steps, not as the centerpiece. The lesson teaches the primitive directly. The lesson explicitly delineates what receipts prove (attribution, integrity, ordering) versus what they do NOT prove (correctness, policy compliance, identity beyond the key). This boundary is treated as the most important single takeaway and is repeated in both the README and the notebook recap. Includes: - README.md (~2,800 words, two Mermaid diagrams, Knowledge Check section, Production Checklist appendix) - code_samples/18-signed-receipts.ipynb (30 cells, 4 self-contained sections, nbformat-validated) - code_samples/requirements.txt (PyNaCl + jcs + ipykernel) - code_samples/sample_receipts/ (three pre-generated fixtures plus reproducible regeneration script) - Root README.md updated to swap the "Coming Soon" entry for a link. Verified end-to-end on a fresh Python 3.13 venv via jupyter nbconvert --execute.
|
👋 Thanks for contributing @tomjwxf! We will review the pull request and get back to you soon. |
There was a problem hiding this comment.
Pull request overview
Adds Lesson 18 (“Securing AI Agents with Cryptographic Receipts”) to the curriculum, including a Python-first written lesson, hands-on notebook, and sample receipt fixtures for offline inspection.
Changes:
- Adds new Lesson 18 README explaining receipt signing, verification, and chaining (with diagrams, knowledge check, and checklist).
- Adds a Jupyter notebook walking through signing → tamper detection → chain integrity → tool wrapper pattern.
- Adds minimal Python dependencies + pre-generated receipt fixtures (and a generator script), and links the lesson from the root curriculum README.
Show a summary per file
| File | Description |
|---|---|
| README.md | Updates the curriculum table to link to the new Lesson 18. |
| 18-securing-ai-agents/README.md | New written lesson content (concepts, examples, diagrams, exercises). |
| 18-securing-ai-agents/code_samples/requirements.txt | Dependency list for running the Lesson 18 notebook. |
| 18-securing-ai-agents/code_samples/18-signed-receipts.ipynb | Hands-on notebook implementing signing/verifying/chaining + a tool wrapper. |
| 18-securing-ai-agents/code_samples/sample_receipts/README.md | Explains the included JSON fixtures and how to verify them. |
| 18-securing-ai-agents/code_samples/sample_receipts/generate_fixtures.py | Script to regenerate the receipt JSON fixtures reproducibly. |
| 18-securing-ai-agents/code_samples/sample_receipts/01_valid_receipt.json | Pre-generated valid receipt fixture. |
| 18-securing-ai-agents/code_samples/sample_receipts/02_tampered_receipt.json | Pre-generated tampered receipt fixture. |
| 18-securing-ai-agents/code_samples/sample_receipts/03_chain_three_receipts.json | Pre-generated 3-link receipt chain fixture. |
Copilot's findings
Tip
Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comments suppressed due to low confidence (2)
18-securing-ai-agents/README.md:165
- The
verify_receiptexample here assumes top-levelsignatureandpublic_keyfields and strips both from the signed payload, but the lesson’s actual receipts embedpublic_keyundersignature(and thesignaturefield is an object). As written, this verification snippet won’t work with the provided fixtures/notebook output; update it to match the real receipt shape and base64url decoding approach used in the notebook (including correct padding handling).
def verify_receipt(receipt: dict) -> bool:
# Separate the signature from the payload
signed_payload = {k: v for k, v in receipt.items()
if k not in ("signature", "public_key")}
# Recompute the canonical hash
canonical_bytes = canonicalize(signed_payload)
message_hash = hashlib.sha256(canonical_bytes).digest()
# Decode the signature and public key
signature = base64.urlsafe_b64decode(receipt["signature"] + "==")
public_key = signing.VerifyKey(
base64.urlsafe_b64decode(receipt["public_key"] + "==")
)
18-securing-ai-agents/code_samples/18-signed-receipts.ipynb:446
verify_chainclaims to check that sequence numbers increase monotonically, but the implementation requiresreceipt["sequence"] == i(exactly equal to the receipt’s position). Either relax the check to true monotonic increase (e.g.,>previous) or adjust the docstring to state that sequences must be contiguous starting at 0, since the current mismatch can confuse learners.
" 3. Sequence numbers must increase monotonically.\n",
" Returns a per-receipt result dict.\n",
" \"\"\"\n",
" results = []\n",
" for i, receipt in enumerate(chain):\n",
" sig_ok = verify_receipt(receipt)\n",
"\n",
" if i == 0:\n",
" chain_ok = receipt[\"previous_receipt_hash\"] is None\n",
" else:\n",
" expected = receipt_hash(chain[i - 1])\n",
" chain_ok = receipt[\"previous_receipt_hash\"] == expected\n",
"\n",
" seq_ok = receipt[\"sequence\"] == i\n",
- Files reviewed: 9/9 changed files
- Comments generated: 6
| [](https://youtu.be/PLACEHOLDER_VIDEO_ID) | ||
|
|
||
| > _(Click the image above to view video of this lesson)_ |
There was a problem hiding this comment.
./images/lesson-18-thumbnail.png is referenced, but this lesson directory doesn't include an images/ folder. This will render a broken thumbnail link in the lesson README; add the expected image asset(s) (or update the path to a shared/valid thumbnail location).
| [](https://youtu.be/PLACEHOLDER_VIDEO_ID) | |
| > _(Click the image above to view video of this lesson)_ | |
| [Watch the lesson video: Securing AI Agents with Cryptographic Receipts](https://youtu.be/PLACEHOLDER_VIDEO_ID) | |
| > _(Use the link above to view the video for this lesson)_ |
There was a problem hiding this comment.
Good catch, fixed in 5d461ba.
I removed the [](youtube) markdown and replaced it with a plain
text link to the placeholder video URL, plus a note that the lesson video and
thumbnail can be added by the Microsoft content team post-merge. This mirrors
how lessons 14 and 15 ship without their own video assets in the lesson
directory, and it removes the broken-image render in the meantime. Happy to
swap in a real thumbnail file under 18-securing-ai-agents/images/ if the
content team would prefer that path.
| "timestamp": "2026-04-25T14:30:00Z", | ||
| "sequence": 47, | ||
| "previous_receipt_hash": "sha256:9d4e6a...", | ||
| "signature": "ed25519:c5af83..." |
There was a problem hiding this comment.
The receipt schema in this README is inconsistent with the notebook + fixtures: here signature is shown as a string, but the notebook/fixtures use a signature object containing alg, sig, and public_key. Align the JSON example(s) with the actual schema used in 18-signed-receipts.ipynb and code_samples/sample_receipts/*.json to avoid confusing readers.
This issue also appears on line 151 of the same file.
| "signature": "ed25519:c5af83..." | |
| "signature": { | |
| "alg": "ed25519", | |
| "sig": "c5af83...", | |
| "public_key": "ed25519-pub:8f3b2c..." | |
| } |
There was a problem hiding this comment.
Fixed in 5d461ba. The README's example receipt now matches the structured
signature object that the notebook and the JSON fixtures actually emit:
"signature": {
"alg": "EdDSA",
"sig": "c5af83...",
"public_key": "8f3b2c..."
}One small departure from the suggested patch: I kept "alg": "EdDSA" rather
than "ed25519" because EdDSA is the registered JOSE/COSE algorithm name
(JWA, RFC 8037) and matches the algorithm identifier already used in the
fixtures and the notebook output. Using EdDSA here also keeps this lesson
forward-compatible with curve25519 vs curve448 variants in the same family.
The public_key field is plain base64url with no ed25519-pub: prefix, again
matching the fixtures.
Both occurrences of the schema example (line 77 and the verify snippet around
line 151) now use the same shape.
| # Build the receipt payload (no signature yet) | ||
| payload = { | ||
| "type": "agent.tool_call.v1", | ||
| "agent_id": "contoso-travel-bot", | ||
| "tool_name": "lookup_flights", | ||
| "tool_args_hash": hash_args({"origin": "SYD", "dest": "LAX"}), | ||
| "result_hash": hash_result(flight_results), | ||
| "policy_id": "contoso-travel-policy-v3", | ||
| "timestamp": "2026-04-25T14:30:00Z", | ||
| "sequence": 47, | ||
| "previous_receipt_hash": prior_chain_link, | ||
| } |
There was a problem hiding this comment.
The signing example uses hash_args, hash_result, flight_results, and prior_chain_link, but these helpers/variables are not defined anywhere in the README, and their naming doesn’t match the notebook’s sha256_canonical(...) approach. Consider either defining these helpers in the README snippet or rewriting the snippet to use the same sha256_canonical + previous_receipt_hash variables used in the notebook so the code is runnable/copy-pastable.
There was a problem hiding this comment.
Fixed in 5d461ba. The signing snippet is now copy-pastable and uses exactly
the same helpers the notebook introduces, so a learner can read either one
first and the other will look familiar.
Specifically:
- Defined
b64url_nopad(...)andsha256_canonical(...)inline in the
snippet (the same helpers the notebook defines). - Replaced the undefined
hash_args(...)/hash_result(...)calls with
sha256_canonical(tool_args)/sha256_canonical(tool_result). - Replaced the undefined
flight_results/prior_chain_linknames with
literal example values (tool_args = {...},tool_result = [...],
previous_receipt_hash: Nonefor the first receipt). - The signing block now produces the structured
signatureobject directly,
matching the schema example earlier in the README and the fixtures.
The snippet is short enough to be readable on the page and runnable as a
standalone Python file given the dependencies in requirements.txt.
| "def verify_chain(chain: list) -> dict:\n", | ||
| " \"\"\"\n", | ||
| " Verify a sequence of receipts:\n", | ||
| " 1. Each receipt's signature must verify.\n", | ||
| " 2. Each receipt (except the genesis) must reference the previous receipt's hash.\n", | ||
| " 3. Sequence numbers must increase monotonically.\n", | ||
| " Returns a per-receipt result dict.\n", |
There was a problem hiding this comment.
verify_chain’s docstring/annotation says it returns a dict and that sequence numbers must be monotonically increasing, but the function actually returns a list of per-receipt dicts. Update the return type in the signature/docstring so it matches the implementation.
This issue also appears on line 433 of the same file.
| "def verify_chain(chain: list) -> dict:\n", | |
| " \"\"\"\n", | |
| " Verify a sequence of receipts:\n", | |
| " 1. Each receipt's signature must verify.\n", | |
| " 2. Each receipt (except the genesis) must reference the previous receipt's hash.\n", | |
| " 3. Sequence numbers must increase monotonically.\n", | |
| " Returns a per-receipt result dict.\n", | |
| "def verify_chain(chain: list) -> list[dict]:\n", | |
| " \"\"\"\n", | |
| " Verify a sequence of receipts:\n", | |
| " 1. Each receipt's signature must verify.\n", | |
| " 2. Each receipt (except the genesis) must reference the previous receipt's hash.\n", | |
| " 3. Sequence numbers must match each receipt's zero-based position in the chain.\n", | |
| " Returns a list of per-receipt result dicts.\n", |
There was a problem hiding this comment.
Fixed in 5d461ba.
- Return annotation changed from
dicttolist[dict], matching what the
function actually returns (one result dict per receipt). - Docstring item 3 changed from "Sequence numbers must increase
monotonically" to "Sequence numbers must match each receipt's zero-based
position in the chain", which is whatseq_ok = receipt["sequence"] == i
actually checks. - Docstring tail changed from "Returns a per-receipt result dict" to
"Returns a list of per-receipt result dicts".
I went with the "tighten the docstring to match the implementation" option
rather than "relax the check", because contiguous-from-zero is the property
the rest of the lesson relies on (the chain demo, the tampering exercise,
and the production checklist all assume sequence == position). A learner who
sees 0, 1, 2, ... in the fixtures gets a strictly stronger guarantee than
mere monotonicity, and there's no scenario in this lesson where receipts
arrive out of order or with gaps.
| " def __call__(self, **kwargs):\n", | ||
| " result = self.fn(**kwargs)\n", | ||
| " previous_hash = receipt_hash(self.receipts[-1]) if self.receipts else None\n", | ||
| " receipt = make_receipt(\n", | ||
| " tool_name=self.name,\n", | ||
| " tool_args=kwargs,\n", |
There was a problem hiding this comment.
The text says ReceiptedTool can wrap “any callable tool function”, but __call__ only accepts keyword arguments and calls self.fn(**kwargs). Tools that require positional args (or *args, **kwargs) will fail; consider supporting *args and including both args/kwargs in the receipt input hash, or narrow the wording to keyword-only tools.
| " def __call__(self, **kwargs):\n", | |
| " result = self.fn(**kwargs)\n", | |
| " previous_hash = receipt_hash(self.receipts[-1]) if self.receipts else None\n", | |
| " receipt = make_receipt(\n", | |
| " tool_name=self.name,\n", | |
| " tool_args=kwargs,\n", | |
| " def __call__(self, *args, **kwargs):\n", | |
| " result = self.fn(*args, **kwargs)\n", | |
| " previous_hash = receipt_hash(self.receipts[-1]) if self.receipts else None\n", | |
| " receipt = make_receipt(\n", | |
| " tool_name=self.name,\n", | |
| " tool_args={\"args\": list(args), \"kwargs\": kwargs},\n", |
There was a problem hiding this comment.
Fixed in 5d461ba. The call signature now accepts both positional and
keyword arguments and records both in the receipt:
def __call__(self, *args, **kwargs):
result = self.fn(*args, **kwargs)
...
receipt = make_receipt(
tool_name=self.name,
tool_args={"args": list(args), "kwargs": kwargs},
...
)This way:
- A keyword-only call records
{"args": [], "kwargs": {...}}, so existing
notebook examples that use kwargs are unaffected. - A positional call records
{"args": [...], "kwargs": {}}and is bound
into the canonical hash, so tampering with the args in the receipt still
invalidates verification. - Mixed-style calls record both.
The class docstring now states this explicitly: "Accepts both positional
and keyword arguments. The receipt's tool_args field records args (as a
list) and kwargs (as a dict) so the canonical hash binds to whichever the
caller supplied."
Section 4 of the notebook still uses kwargs in its example, which produces
exactly the same bytes-on-the-wire as before this change, so the fixtures
and the rest of the lesson are unaffected.
| import hashlib | ||
| import json | ||
| import base64 | ||
| from datetime import datetime, timezone |
There was a problem hiding this comment.
datetime and timezone are imported but never used (fixtures use the fixed FIXED_TS string). Removing unused imports will keep this fixture generator clean and avoid linter warnings.
| from datetime import datetime, timezone |
There was a problem hiding this comment.
Fixed in 5d461ba. Removed the from datetime import datetime, timezone
line. The fixture generator only ever uses the constant FIXED_TS string
to keep output byte-reproducible, so the datetime machinery was dead
weight.
Re-running the script after the change still produces byte-identical
fixtures (verified against the JSON files committed in 209eb6c).
|
@tomjwxf please see comments above Ensure the readme and sequential number is correct and the flow of the modules is complete |
README.md
* Replace broken thumbnail image link with a plain text link, mirroring
the lesson 14 / 15 pattern, with a note that the Microsoft content
team can swap in a thumbnail post-merge.
* Update the example receipt JSON so signature is a structured object
{alg, sig, public_key} matching the notebook output and the JSON
fixtures under code_samples/sample_receipts/.
* Rewrite the "Producing a Receipt in Python" snippet so it uses the
same sha256_canonical helper and previous_receipt_hash variable
introduced by the notebook. Removes the previously undefined
hash_args, hash_result, flight_results, and prior_chain_link names
so the snippet is copy-pastable.
* Rewrite the "Verifying a Receipt" snippet so it reads the structured
signature object (alg / sig / public_key) and uses an explicit
b64url_decode helper for padding-safe base64url decoding.
code_samples/18-signed-receipts.ipynb
* verify_chain: change return annotation from dict to list[dict] and
update the docstring to say "Sequence numbers must match each
receipt's zero-based position in the chain. Returns a list of
per-receipt result dicts." This now matches what the function
actually returns.
* ReceiptedTool.__call__: accept *args in addition to **kwargs so
tools that take positional arguments can still be wrapped. Record
both via tool_args={"args": list(args), "kwargs": kwargs} so the
canonical hash binds to whichever the caller supplied. Class
docstring updated to explain the args / kwargs handling.
code_samples/sample_receipts/generate_fixtures.py
* Remove the unused datetime / timezone imports (the script uses the
fixed FIXED_TS string).
The notebook still executes end-to-end (jupyter nbconvert --execute,
all 30 cells pass) and the regenerated fixtures are byte-identical to
the previous fixtures committed in 209eb6c.
Three small additive improvements to point learners at the broader
receipt ecosystem without expanding what the lesson teaches.
README.md
* New "Beyond This Lesson" section between Production Checklist and
Additional Resources, with six bullets naming the next concepts in
the same family: selective disclosure, receipt revocation,
bilateral / split-signature receipts, payload composition, cross-
implementation conformance, and post-quantum migration. Each is
one paragraph; none requires the lesson to teach the underlying
math or algorithm.
* Practice Exercise: stretch challenge split into two. Stretch 1 is
the existing "extend the schema with a custom field" challenge
(unchanged). Stretch 2 introduces the Merkle-commitment pattern
by having the learner SHA-256-hash two receipts together and
embed the digest in a third, demonstrating a one-step inclusion
proof using only primitives the lesson already taught.
* Additional Resources: added two entries:
- RFC 6962: Certificate Transparency (the Merkle-tree
construction underpinning selective-disclosure receipts)
- Cross-implementation conformance test vectors for the receipt
format used in this lesson (Apache-2.0)
The notebook still executes end-to-end (jupyter nbconvert --execute,
all 30 cells pass) and the regenerated fixtures remain byte-identical.
|
Thanks for the careful review, Lee. Pushed two commits, 5d461ba (Copilot 5d461ba (Copilot review):
f1bcb4b (forward-pointing additions, no scope expansion):
I re-ran the notebook end-to-end after each commit ( On your two specific asks: Sequential lesson number. Slot 18 was already reserved in the Module flow. The lesson assumes lessons 1 to 11 as background If there's appetite, two natural follow-up lessons exist that I would be
Both topics are real, both are discoverable from the receipt format Otherwise the PR has been rebased to the new commits and CI should re-run |
Summary
Adds the Lesson 18 content @leestott green-lit in #503. Python-first hands-on lesson on cryptographic receipts for agent tool calls.
Contents
18-securing-ai-agents/README.md(~2,800 words) covering the audit-trail problem, what a receipt is, signing, verification, chaining, the boundary of what receipts prove, and production references. Includes two Mermaid diagrams (flow + chain), a five-question Knowledge Check, and a six-item Production Checklist.code_samples/18-signed-receipts.ipynb(30 cells, four self-contained sections covering signing → tamper detection → chain integrity → tool wrapper).code_samples/requirements.txt(PyNaCl + jcs + ipykernel only; no Azure dependency required to run the lesson).code_samples/sample_receipts/(three pre-generated receipt JSONs for inspection without running the notebook, plus a reproducible regeneration script).README.mdupdated to swap the "Coming Soon" entry for the link.Pedagogical choices
The lesson teaches the cryptographic primitive directly in plain Python (about 50 lines of signing + verification logic students can read line by line) rather than leading with any specific tool. Production tools are referenced at the end of the lesson, not the centerpiece, so students leave with a foundational understanding rather than a tool dependency.
The lesson explicitly delineates what receipts prove (attribution, integrity, ordering) versus what they do not (correctness, policy compliance, identity beyond the key). This boundary is the single most important takeaway and is repeated in both the README and the notebook recap.
Test plan
pip install -r requirements.txt. Verified viajupyter nbconvert --to notebook --execute.Open questions for reviewers
18-to match the existing slot in the curriculum table's "Coming Soon" row. If 17 or 19 fits better, trivial to renumber.(To be determined by curriculum maintainers). Happy to update once the next lesson is confirmed.CLA: previously confirmed via earlier contributions to
microsoft/agent-governance-toolkit.🤖 Generated with Claude Code