679 lines
26 KiB
Plaintext
679 lines
26 KiB
Plaintext
{
|
|
"cells": [
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "7cb57965",
|
|
"metadata": {},
|
|
"source": [
|
|
"# Lesson 18: Securing AI Agents with Cryptographic Receipts\n",
|
|
"\n",
|
|
"## Hands-on Notebook\n",
|
|
"\n",
|
|
"This notebook walks through four exercises:\n",
|
|
"\n",
|
|
"1. **Sign your first receipt** for an agent tool call and verify it.\n",
|
|
"2. **Tamper with the receipt** and watch verification fail.\n",
|
|
"3. **Build a three-receipt chain** and confirm chain integrity.\n",
|
|
"4. **Wrap a Microsoft Agent Framework tool call** so every action emits a receipt.\n",
|
|
"\n",
|
|
"All cryptographic primitives are imported from well-maintained libraries (`pynacl` for Ed25519, `jcs` for RFC 8785 canonical JSON, `hashlib` from the Python standard library for SHA-256). The receipt logic itself is plain Python that you can read and modify.\n",
|
|
"\n",
|
|
"Run the cells in order. Each section is short and self-contained."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "dbf4de7e",
|
|
"metadata": {},
|
|
"source": [
|
|
"## Setup\n",
|
|
"\n",
|
|
"Install the two dependencies. Both have permissive licenses (Apache-2.0 / MIT)."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"id": "be69941d",
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"!pip install -q pynacl jcs"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"id": "76f7d45e",
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"import json\n",
|
|
"import hashlib\n",
|
|
"import base64\n",
|
|
"from datetime import datetime, timezone\n",
|
|
"\n",
|
|
"from nacl import signing\n",
|
|
"from nacl.exceptions import BadSignatureError\n",
|
|
"from jcs import canonicalize"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "bbfff4be",
|
|
"metadata": {},
|
|
"source": [
|
|
"## Helper Utilities\n",
|
|
"\n",
|
|
"These two helpers handle base64url encoding (without padding) and SHA-256 hashing of arbitrary objects. They keep the rest of the notebook focused on the receipt logic itself."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"id": "df7e2c63",
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"def b64url_nopad(data: bytes) -> str:\n",
|
|
" \"\"\"Base64url-encode bytes without padding (RFC 4648 Section 5).\"\"\"\n",
|
|
" return base64.urlsafe_b64encode(data).decode(\"ascii\").rstrip(\"=\")\n",
|
|
"\n",
|
|
"def b64url_decode(s: str) -> bytes:\n",
|
|
" \"\"\"Decode a base64url string that may be missing padding.\"\"\"\n",
|
|
" padding = \"=\" * ((4 - len(s) % 4) % 4)\n",
|
|
" return base64.urlsafe_b64decode(s + padding)\n",
|
|
"\n",
|
|
"def sha256_canonical(obj) -> str:\n",
|
|
" \"\"\"\n",
|
|
" SHA-256 hash of a Python object, computed over its JCS-canonical JSON form.\n",
|
|
" Returns a 'sha256:' prefixed hex digest so callers can identify the algorithm.\n",
|
|
" \"\"\"\n",
|
|
" canonical = canonicalize(obj)\n",
|
|
" digest = hashlib.sha256(canonical).hexdigest()\n",
|
|
" return f\"sha256:{digest}\""
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "6dd4258f",
|
|
"metadata": {},
|
|
"source": [
|
|
"## Section 1: Sign your first receipt\n",
|
|
"\n",
|
|
"Imagine our agent for **Contoso Travel** just looked up flights from Sydney to Los Angeles for a customer. We want to record this tool call as a signed receipt so a future auditor can verify it without trusting us.\n",
|
|
"\n",
|
|
"### Step 1.1: Generate a signing key\n",
|
|
"\n",
|
|
"In production, the agent's signing key would live in a hardware security module (HSM), Azure Key Vault, or a similar protected store. For this lesson we generate a fresh key in memory."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"id": "34f1cd12",
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"signing_key = signing.SigningKey.generate()\n",
|
|
"verify_key = signing_key.verify_key\n",
|
|
"\n",
|
|
"public_key_b64 = b64url_nopad(bytes(verify_key))\n",
|
|
"print(f\"Public key (Ed25519, 32 bytes): {public_key_b64}\")"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "77dffc86",
|
|
"metadata": {},
|
|
"source": [
|
|
"### Step 1.2: Build the receipt payload\n",
|
|
"\n",
|
|
"The payload contains everything we want the receipt to attest to: who acted, what tool, with what arguments, what came back, under what policy, and when. We hash the arguments and result rather than including them inline so the receipt does not leak sensitive content."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"id": "dfa7feb1",
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"tool_args = {\n",
|
|
" \"origin\": \"SYD\",\n",
|
|
" \"destination\": \"LAX\",\n",
|
|
" \"departure_date\": \"2026-06-15\",\n",
|
|
" \"passengers\": 2,\n",
|
|
"}\n",
|
|
"\n",
|
|
"tool_result = [\n",
|
|
" {\"flight\": \"QF11\", \"price\": 1850, \"stops\": 0},\n",
|
|
" {\"flight\": \"UA864\", \"price\": 1620, \"stops\": 1},\n",
|
|
" {\"flight\": \"DL11\", \"price\": 1740, \"stops\": 0},\n",
|
|
"]\n",
|
|
"\n",
|
|
"payload = {\n",
|
|
" \"type\": \"agent.tool_call.v1\",\n",
|
|
" \"agent_id\": \"contoso-travel-bot\",\n",
|
|
" \"tool_name\": \"lookup_flights\",\n",
|
|
" \"tool_args_hash\": sha256_canonical(tool_args),\n",
|
|
" \"result_hash\": sha256_canonical(tool_result),\n",
|
|
" \"policy_id\": \"contoso-travel-policy-v3\",\n",
|
|
" \"timestamp\": datetime.now(timezone.utc).strftime(\"%Y-%m-%dT%H:%M:%SZ\"),\n",
|
|
" \"sequence\": 0,\n",
|
|
" \"previous_receipt_hash\": None,\n",
|
|
"}\n",
|
|
"\n",
|
|
"print(json.dumps(payload, indent=2))"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "e83582c1",
|
|
"metadata": {},
|
|
"source": [
|
|
"### Step 1.3: Sign and assemble the receipt\n",
|
|
"\n",
|
|
"Three steps:\n",
|
|
"\n",
|
|
"1. Canonicalize the payload using JCS so two implementations producing the same logical receipt produce byte-identical bytes.\n",
|
|
"2. Hash the canonical bytes with SHA-256.\n",
|
|
"3. Sign the hash with the Ed25519 private key.\n",
|
|
"\n",
|
|
"The signature is then attached to the original payload to produce the final receipt."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"id": "f39e3230",
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"def sign_receipt(payload: dict, signing_key: signing.SigningKey, verify_key) -> dict:\n",
|
|
" \"\"\"\n",
|
|
" Sign a receipt payload. Returns the receipt with attached signature and public key.\n",
|
|
" The 'signature' and 'public_key' fields are NOT part of the canonical signed bytes.\n",
|
|
" \"\"\"\n",
|
|
" canonical = canonicalize(payload)\n",
|
|
" message_hash = hashlib.sha256(canonical).digest()\n",
|
|
" signature_bytes = signing_key.sign(message_hash).signature\n",
|
|
" return {\n",
|
|
" **payload,\n",
|
|
" \"signature\": {\n",
|
|
" \"alg\": \"EdDSA\",\n",
|
|
" \"sig\": b64url_nopad(signature_bytes),\n",
|
|
" \"public_key\": b64url_nopad(bytes(verify_key)),\n",
|
|
" },\n",
|
|
" }\n",
|
|
"\n",
|
|
"receipt = sign_receipt(payload, signing_key, verify_key)\n",
|
|
"print(json.dumps(receipt, indent=2))"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "95977abc",
|
|
"metadata": {},
|
|
"source": [
|
|
"### Step 1.4: Verify the receipt\n",
|
|
"\n",
|
|
"Verification reverses the process. We strip the signature, recompute the canonical hash, and check the signature against the public key in the receipt.\n",
|
|
"\n",
|
|
"An auditor doing this verification needs nothing from us except the receipt itself. No service to call, no key directory to query, no trust required."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"id": "15c94d27",
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"def verify_receipt(receipt: dict) -> bool:\n",
|
|
" \"\"\"\n",
|
|
" Verify a receipt's Ed25519 signature.\n",
|
|
" Returns True if valid, False otherwise.\n",
|
|
" \"\"\"\n",
|
|
" sig_obj = receipt.get(\"signature\")\n",
|
|
" if not sig_obj or sig_obj.get(\"alg\") != \"EdDSA\":\n",
|
|
" return False\n",
|
|
"\n",
|
|
" # Reconstruct the payload that was actually signed (everything except signature).\n",
|
|
" payload = {k: v for k, v in receipt.items() if k != \"signature\"}\n",
|
|
"\n",
|
|
" canonical = canonicalize(payload)\n",
|
|
" message_hash = hashlib.sha256(canonical).digest()\n",
|
|
"\n",
|
|
" try:\n",
|
|
" verify_key = signing.VerifyKey(b64url_decode(sig_obj[\"public_key\"]))\n",
|
|
" verify_key.verify(message_hash, b64url_decode(sig_obj[\"sig\"]))\n",
|
|
" return True\n",
|
|
" except BadSignatureError:\n",
|
|
" return False\n",
|
|
" except Exception as exc:\n",
|
|
" print(f\"Verification error: {exc}\")\n",
|
|
" return False\n",
|
|
"\n",
|
|
"is_valid = verify_receipt(receipt)\n",
|
|
"print(f\"Receipt is valid: {is_valid}\")"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "d25454d4",
|
|
"metadata": {},
|
|
"source": [
|
|
"You should see `Receipt is valid: True`. The agent has produced its first cryptographically signed audit record."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "1c177405",
|
|
"metadata": {},
|
|
"source": [
|
|
"## Section 2: Tamper with the receipt\n",
|
|
"\n",
|
|
"The whole point of receipts is that they are tamper-evident. Let's prove it.\n",
|
|
"\n",
|
|
"We will modify exactly one character of the receipt and watch verification fail."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"id": "3038feec",
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"import copy\n",
|
|
"\n",
|
|
"tampered = copy.deepcopy(receipt)\n",
|
|
"\n",
|
|
"# Modify the policy_id field (this is what an attacker might do to claim\n",
|
|
"# the action was governed by a more permissive policy than was actually used).\n",
|
|
"original_policy = tampered[\"policy_id\"]\n",
|
|
"tampered[\"policy_id\"] = \"contoso-travel-policy-PERMISSIVE\"\n",
|
|
"\n",
|
|
"print(f\"Original policy_id: {original_policy}\")\n",
|
|
"print(f\"Tampered policy_id: {tampered['policy_id']}\")\n",
|
|
"print()\n",
|
|
"print(f\"Tampered receipt valid? {verify_receipt(tampered)}\")"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "cd3e71c5",
|
|
"metadata": {},
|
|
"source": [
|
|
"### What just happened?\n",
|
|
"\n",
|
|
"When we changed `policy_id`, the canonical bytes changed. The SHA-256 hash of those bytes changed. The signature (which was over the original hash) no longer matches the new hash. Verification correctly returns `False`.\n",
|
|
"\n",
|
|
"There is no way to modify any field of the receipt and still have it verify, unless the attacker has the private key. As long as the private key is in a key vault and the public key is published, tampering is impossible to hide.\n",
|
|
"\n",
|
|
"Try it yourself: modify the `tool_name` or `agent_id` or `timestamp` in the cell above and re-run. Every change produces an invalid receipt."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "8ba543c8",
|
|
"metadata": {},
|
|
"source": [
|
|
"## Section 3: Chain receipts together\n",
|
|
"\n",
|
|
"A single receipt protects one action. Most agents take many actions. To make the entire sequence tamper-evident, we link each receipt to the previous one by including the previous receipt's hash in the new receipt's payload.\n",
|
|
"\n",
|
|
"```text\n",
|
|
"Receipt 0 --> Receipt 1 --> Receipt 2\n",
|
|
" | |\n",
|
|
" +-- previous_receipt_hash field --+\n",
|
|
"```\n",
|
|
"\n",
|
|
"If anyone removes or reorders a receipt, the chain breaks at exactly that point. Verification of any later receipt fails because its `previous_receipt_hash` no longer matches the actual hash of its predecessor."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"id": "c7c6473b",
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"def receipt_hash(receipt: dict) -> str:\n",
|
|
" \"\"\"\n",
|
|
" Compute the chain hash of a complete receipt (including signature).\n",
|
|
" This becomes the previous_receipt_hash of the next receipt in the chain.\n",
|
|
" \"\"\"\n",
|
|
" canonical = canonicalize(receipt)\n",
|
|
" digest = hashlib.sha256(canonical).hexdigest()\n",
|
|
" return f\"sha256:{digest}\"\n",
|
|
"\n",
|
|
"def make_receipt(\n",
|
|
" tool_name: str,\n",
|
|
" tool_args: dict,\n",
|
|
" tool_result,\n",
|
|
" sequence: int,\n",
|
|
" previous_receipt_hash,\n",
|
|
" signing_key,\n",
|
|
" verify_key,\n",
|
|
" agent_id: str = \"contoso-travel-bot\",\n",
|
|
" policy_id: str = \"contoso-travel-policy-v3\",\n",
|
|
") -> dict:\n",
|
|
" \"\"\"Convenience: build, sign, and return a receipt for one tool call.\"\"\"\n",
|
|
" payload = {\n",
|
|
" \"type\": \"agent.tool_call.v1\",\n",
|
|
" \"agent_id\": agent_id,\n",
|
|
" \"tool_name\": tool_name,\n",
|
|
" \"tool_args_hash\": sha256_canonical(tool_args),\n",
|
|
" \"result_hash\": sha256_canonical(tool_result),\n",
|
|
" \"policy_id\": policy_id,\n",
|
|
" \"timestamp\": datetime.now(timezone.utc).strftime(\"%Y-%m-%dT%H:%M:%SZ\"),\n",
|
|
" \"sequence\": sequence,\n",
|
|
" \"previous_receipt_hash\": previous_receipt_hash,\n",
|
|
" }\n",
|
|
" return sign_receipt(payload, signing_key, verify_key)"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"id": "1b8b1231",
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"# Build a chain of three receipts: search, hold, book.\n",
|
|
"r0 = make_receipt(\n",
|
|
" tool_name=\"lookup_flights\",\n",
|
|
" tool_args={\"origin\": \"SYD\", \"destination\": \"LAX\", \"date\": \"2026-06-15\"},\n",
|
|
" tool_result=[{\"flight\": \"QF11\", \"price\": 1850}],\n",
|
|
" sequence=0,\n",
|
|
" previous_receipt_hash=None,\n",
|
|
" signing_key=signing_key,\n",
|
|
" verify_key=verify_key,\n",
|
|
")\n",
|
|
"\n",
|
|
"r1 = make_receipt(\n",
|
|
" tool_name=\"hold_seat\",\n",
|
|
" tool_args={\"flight\": \"QF11\", \"seat\": \"14A\", \"hold_minutes\": 30},\n",
|
|
" tool_result={\"hold_id\": \"H8472\", \"expires_at\": \"2026-06-15T15:00:00Z\"},\n",
|
|
" sequence=1,\n",
|
|
" previous_receipt_hash=receipt_hash(r0),\n",
|
|
" signing_key=signing_key,\n",
|
|
" verify_key=verify_key,\n",
|
|
")\n",
|
|
"\n",
|
|
"r2 = make_receipt(\n",
|
|
" tool_name=\"confirm_booking\",\n",
|
|
" tool_args={\"hold_id\": \"H8472\", \"payment_token\": \"tok_redacted\"},\n",
|
|
" tool_result={\"booking_ref\": \"CT-09182\", \"status\": \"confirmed\"},\n",
|
|
" sequence=2,\n",
|
|
" previous_receipt_hash=receipt_hash(r1),\n",
|
|
" signing_key=signing_key,\n",
|
|
" verify_key=verify_key,\n",
|
|
")\n",
|
|
"\n",
|
|
"chain = [r0, r1, r2]\n",
|
|
"for i, r in enumerate(chain):\n",
|
|
" print(f\"Receipt {i}: tool={r['tool_name']}, prev={r['previous_receipt_hash']}\")"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"id": "5dd91fcc",
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"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",
|
|
" \"\"\"\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",
|
|
"\n",
|
|
" results.append({\n",
|
|
" \"index\": i,\n",
|
|
" \"tool\": receipt[\"tool_name\"],\n",
|
|
" \"signature_valid\": sig_ok,\n",
|
|
" \"chain_link_valid\": chain_ok,\n",
|
|
" \"sequence_valid\": seq_ok,\n",
|
|
" \"overall_valid\": sig_ok and chain_ok and seq_ok,\n",
|
|
" })\n",
|
|
" return results\n",
|
|
"\n",
|
|
"for r in verify_chain(chain):\n",
|
|
" status = \"VALID\" if r[\"overall_valid\"] else \"INVALID\"\n",
|
|
" print(f\"Receipt {r['index']} ({r['tool']:>18}): {status}\")"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "33b27f08",
|
|
"metadata": {},
|
|
"source": [
|
|
"Now break the chain by tampering with the middle receipt and re-verify. The tampered receipt fails its signature check, AND the next receipt fails its chain-link check (because its `previous_receipt_hash` no longer matches the modified middle receipt's hash)."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"id": "12d3201f",
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"# Tamper with the middle receipt: change the hold duration to something\n",
|
|
"# more permissive than was actually authorized.\n",
|
|
"tampered_chain = [copy.deepcopy(r) for r in chain]\n",
|
|
"tampered_chain[1][\"tool_args_hash\"] = sha256_canonical(\n",
|
|
" {\"flight\": \"QF11\", \"seat\": \"14A\", \"hold_minutes\": 9999}\n",
|
|
")\n",
|
|
"\n",
|
|
"for r in verify_chain(tampered_chain):\n",
|
|
" status = \"VALID\" if r[\"overall_valid\"] else \"INVALID\"\n",
|
|
" why = \"\"\n",
|
|
" if not r[\"overall_valid\"]:\n",
|
|
" reasons = []\n",
|
|
" if not r[\"signature_valid\"]:\n",
|
|
" reasons.append(\"signature\")\n",
|
|
" if not r[\"chain_link_valid\"]:\n",
|
|
" reasons.append(\"chain link\")\n",
|
|
" if not r[\"sequence_valid\"]:\n",
|
|
" reasons.append(\"sequence\")\n",
|
|
" why = \" (failed: \" + \", \".join(reasons) + \")\"\n",
|
|
" print(f\"Receipt {r['index']} ({r['tool']:>18}): {status}{why}\")"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "910d9eb2",
|
|
"metadata": {},
|
|
"source": [
|
|
"Receipt 0 still verifies (it was not modified and has no predecessor to depend on). Receipt 1 fails its signature check because we changed `tool_args_hash`. Receipt 2 fails its chain-link check because its `previous_receipt_hash` was computed against the original (now-modified) receipt 1.\n",
|
|
"\n",
|
|
"Even if an attacker re-signs the modified receipt 1 (which they cannot do without the private key), the chain-link mismatch in receipt 2 would still expose the tampering. To hide the change, the attacker would have to re-sign every receipt from the modification point onward, which requires possession of the private key."
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "bcad987a",
|
|
"metadata": {},
|
|
"source": [
|
|
"## Section 4: Wrap an agent tool call with receipt signing\n",
|
|
"\n",
|
|
"In a real deployment, you do not want every agent author to remember to call `make_receipt`. You want receipt signing to be automatic for every tool invocation.\n",
|
|
"\n",
|
|
"Here is the simplest pattern: a wrapper class that takes any callable tool function and returns a receipt-emitting version of it. This adapts to any agent framework, including the Microsoft Agent Framework (`agent_framework.foundry`).\n",
|
|
"\n",
|
|
"If you do not have a Microsoft Foundry project set up, the local mock below still demonstrates the pattern.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"id": "7ee11c4a",
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"class ReceiptedTool:\n",
|
|
" \"\"\"\n",
|
|
" Wraps a tool function so every invocation produces a signed receipt.\n",
|
|
" Receipts are appended to a chain held by this object.\n",
|
|
"\n",
|
|
" Accepts both positional and keyword arguments. The receipt's\n",
|
|
" tool_args field records args (as a list) and kwargs (as a dict)\n",
|
|
" so the canonical hash binds to whichever the caller supplied.\n",
|
|
" \"\"\"\n",
|
|
"\n",
|
|
" def __init__(self, name: str, fn, signing_key, verify_key, agent_id: str, policy_id: str):\n",
|
|
" self.name = name\n",
|
|
" self.fn = fn\n",
|
|
" self.signing_key = signing_key\n",
|
|
" self.verify_key = verify_key\n",
|
|
" self.agent_id = agent_id\n",
|
|
" self.policy_id = policy_id\n",
|
|
" self.receipts: list = []\n",
|
|
"\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",
|
|
" tool_result=result,\n",
|
|
" sequence=len(self.receipts),\n",
|
|
" previous_receipt_hash=previous_hash,\n",
|
|
" signing_key=self.signing_key,\n",
|
|
" verify_key=self.verify_key,\n",
|
|
" agent_id=self.agent_id,\n",
|
|
" policy_id=self.policy_id,\n",
|
|
" )\n",
|
|
" self.receipts.append(receipt)\n",
|
|
" return result"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "code",
|
|
"execution_count": null,
|
|
"id": "6fe5e0d8",
|
|
"metadata": {},
|
|
"outputs": [],
|
|
"source": [
|
|
"# Example tool: a mock flight lookup. In a real Microsoft Agent Framework deployment,\n",
|
|
"# this would be a function passed to FoundryChatClient as a tool.\n",
|
|
"def mock_lookup_flights(origin: str, destination: str, departure_date: str) -> list:\n",
|
|
" return [\n",
|
|
" {\"flight\": \"QF11\", \"price\": 1850, \"stops\": 0},\n",
|
|
" {\"flight\": \"UA864\", \"price\": 1620, \"stops\": 1},\n",
|
|
" ]\n",
|
|
"\n",
|
|
"# Wrap it with receipt signing.\n",
|
|
"receipted_lookup = ReceiptedTool(\n",
|
|
" name=\"lookup_flights\",\n",
|
|
" fn=mock_lookup_flights,\n",
|
|
" signing_key=signing_key,\n",
|
|
" verify_key=verify_key,\n",
|
|
" agent_id=\"contoso-travel-bot\",\n",
|
|
" policy_id=\"contoso-travel-policy-v3\",\n",
|
|
")\n",
|
|
"\n",
|
|
"# Use the wrapped tool exactly like the original.\n",
|
|
"results_a = receipted_lookup(origin=\"SYD\", destination=\"LAX\", departure_date=\"2026-06-15\")\n",
|
|
"results_b = receipted_lookup(origin=\"SYD\", destination=\"NRT\", departure_date=\"2026-07-02\")\n",
|
|
"results_c = receipted_lookup(origin=\"MEL\", destination=\"SIN\", departure_date=\"2026-08-10\")\n",
|
|
"\n",
|
|
"print(f\"Tool was called {len(receipted_lookup.receipts)} times.\")\n",
|
|
"print(f\"Each call produced a signed receipt linked to the previous one.\")\n",
|
|
"print()\n",
|
|
"\n",
|
|
"for r in verify_chain(receipted_lookup.receipts):\n",
|
|
" status = \"VALID\" if r[\"overall_valid\"] else \"INVALID\"\n",
|
|
" print(f\"Receipt {r['index']} ({r['tool']}): {status}\")\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "28449512",
|
|
"metadata": {},
|
|
"source": [
|
|
"### Integrating with the Microsoft Agent Framework\n",
|
|
"\n",
|
|
"The `ReceiptedTool` wrapper above is framework-agnostic. To use it inside an agent built with the Microsoft Agent Framework, register the wrapped function as a tool. A sketch (you would replace the mock with a real Microsoft Foundry tool registration):\n",
|
|
"\n",
|
|
"```python\n",
|
|
"# Pseudocode showing the integration shape.\n",
|
|
"# import os\n",
|
|
"# from agent_framework.foundry import FoundryChatClient\n",
|
|
"# from azure.identity import AzureCliCredential\n",
|
|
"#\n",
|
|
"# provider = FoundryChatClient(\n",
|
|
"# project_endpoint=os.environ[\"AZURE_AI_PROJECT_ENDPOINT\"],\n",
|
|
"# model=os.environ[\"AZURE_AI_MODEL_DEPLOYMENT_NAME\"],\n",
|
|
"# credential=AzureCliCredential(),\n",
|
|
"# )\n",
|
|
"# agent = provider.as_agent(\n",
|
|
"# instructions=\"You are a Contoso Travel agent ...\",\n",
|
|
"# tools=[receipted_lookup], # the wrapped tool, not the raw function\n",
|
|
"# )\n",
|
|
"# response = agent.run(\"Find flights from Sydney to Los Angeles in June.\")\n",
|
|
"#\n",
|
|
"# # After the run, every tool call the agent made has a signed receipt:\n",
|
|
"# audit_chain = receipted_lookup.receipts\n",
|
|
"```\n",
|
|
"\n",
|
|
"The agent framework does not need to know anything about receipts. Receipt signing is wrapped around the tool, not bolted into the framework. This is how you add provenance to existing agent code without rewriting the agent.\n"
|
|
]
|
|
},
|
|
{
|
|
"cell_type": "markdown",
|
|
"id": "251ee7e0",
|
|
"metadata": {},
|
|
"source": [
|
|
"## Recap and stretch challenge\n",
|
|
"\n",
|
|
"You have:\n",
|
|
"\n",
|
|
"- Generated an Ed25519 key pair.\n",
|
|
"- Built and signed a receipt for an agent tool call.\n",
|
|
"- Verified the receipt offline using only the public key.\n",
|
|
"- Tampered with a receipt and observed verification fail.\n",
|
|
"- Built a hash-chained sequence of three receipts.\n",
|
|
"- Tampered with the middle of the chain and observed both signature failure and chain-link failure.\n",
|
|
"- Wrapped a tool function with automatic receipt signing.\n",
|
|
"\n",
|
|
"**Stretch challenge.** Extend the receipt schema with a `request_id` field (a UUID for distributed tracing). Update `make_receipt` to include it, and confirm receipts still verify end to end. Then modify the field after signing and confirm verification fails. This forces you to internalize how every byte of the canonical encoding contributes to the signature.\n",
|
|
"\n",
|
|
"**Important boundary.** Receipts prove three things and only three things: attribution (this key signed this content), integrity (the content has not changed since signing), and ordering (this receipt came after that receipt). They do NOT prove that the agent's action was correct, that the policy named in `policy_id` was actually evaluated, or that the agent followed every rule. Receipts are a foundation. Governance is the system you build on top.\n",
|
|
"\n",
|
|
"Read the lesson README again with that boundary in mind. The most common mistake teams make with receipts is assuming \"we have receipts\" means \"we are governed.\" It does not. Receipts make agent behavior auditable. They do not make it correct."
|
|
]
|
|
}
|
|
],
|
|
"metadata": {
|
|
"kernelspec": {
|
|
"display_name": "Python 3",
|
|
"language": "python",
|
|
"name": "python3"
|
|
},
|
|
"language_info": {
|
|
"name": "python",
|
|
"version": "3.10"
|
|
}
|
|
},
|
|
"nbformat": 4,
|
|
"nbformat_minor": 5
|
|
}
|