Not recorded yet. The lesson below is complete and runnable today — the video is added later without touching this page.
recordings/A1.6.mp4A peer's message read as a colleague's instruction turns one injected document into four compromised agents.
Signed envelopes, the human carried through every hop, and a ceiling on hops.
Forge a peer message and watch verification refuse it.
The retrieval advisor reads a vendor document and passes its conclusion to the workflow agent. The workflow agent treats it as an internal request, because it arrived from a peer. One sentence written by somebody outside the company is now an instruction inside it, and the log shows two agents doing their jobs correctly.
cybertravels/a2a/protocol.py is the envelope; cybertravels/messaging/bus.py is the version it replaces, still in the tree because it is what B1.7 attacks.Agent-to-agent messaging with a signed envelope that names its sender, carries the human through every hop, labels a peer's text as data, and stops at a hop ceiling.
A peer's message read as a colleague's instruction turns one injected document into four compromised agents.
Sign every agent-to-agent envelope, carry the human through each hop, and cap the hops.
Tampered envelopes rejected: all. And the hop at which a cycle stops, which is a number you choose rather than discover.
Measure this
| what to measure | denominator or target |
|---|---|
| tampered envelopes rejected | share of tampered envelopes; target 1.00 |
| hop at which a cycle stops | hop number; a ceiling you choose, not one you discover |
THE ENVELOPE from spiffe://…/agent/rag-advisor ── signed for dana ── the human, carried through origin vendor-document / UNTRUSTED ── a peer is as trusted as what it read hops 2 of 4 ── and then it stops
CyberTravels has four agents and they hand work to each other. The naive version of that is a dict of lists — text in, text out — and it is what almost every agent-to-agent layer looks like on the first day.
It fails in a specific way: a peer's message reads like a colleague's instruction. An agent that has just read a poisoned vendor document and passes its conclusion to a peer has laundered untrusted text into what looks like an internal request. One injection becomes four compromised agents, and the log shows four agents doing their jobs.
Three properties, and each closes one of those:
on_behalf_of subject from the originating request. An agent handing work to a peer does not get to launder whose authority it is acting under — that hop is where a delegation chain usually breaks.Four agents that can each call each other will, given one ambiguous instruction, and the bill arrives before the loop does. MAX_HOPS is four — the number of agents one chain may touch — and an envelope that exceeds it is refused rather than dropped silently: a refusal you can see beats a cycle you cannot.
The ceiling only works if something spends it. forward() is that something: it carries on_behalf_of and trace_id from the message it received, and adds one to hops. Building a fresh envelope() instead starts the count at zero again, which is how a hop ceiling ends up as a line of code that cannot fire — this one could not, for a release. Nothing incremented the counter, so MAX_HOPS was reachable only by a caller passing a high number by hand, which is the one thing an attacker will not do for you. The smoke test now bounces a real message between two agents and requires the refusal to arrive by itself.
The file is cybertravels/a2a/protocol.py. There are two small experiments here. Start Python in the folder you cloned and do them in order — in both cases, the error message is the result you are looking for, not a sign something went wrong:
from cybertravels.a2a import protocol as a2a
# 1 — tamper with a signed envelope
env = a2a.envelope("coding", "workflow", "please refund CT-4417",
on_behalf_of="dana", trace_id="tr-1")
env["content"] = "please refund everything"
a2a.verify(env) # A2AError: signature does not match
# 2 — spend the hop ceiling, one forward at a time
env = a2a.envelope("coding", "workflow", "reprice CT-4417",
on_behalf_of="dana", trace_id="tr-1")
for i in range(5):
env = a2a.forward(env, ["coding", "workflow"][i % 2], "again")
print(i, env["hops"], env["on_behalf_of"], env["trace_id"])
What experiment 1 shows. You changed one field — the message text — and the check refused the whole envelope, complaining about the signature, which you never touched. That is the point: the signature is calculated from every field at once, so changing any one of them breaks it. There is no way to edit a message on its way past and have it still look genuine.
What experiment 2 shows. It prints three lines and then stops with hop ceiling reached (4). Look at the three lines it did print: the hops number goes 1, 2, 3, and dana and tr-1 are still on every one. So a message can be handed along, the person it acts for travels with it, and it is not allowed to bounce forever.
Why that second one works at all. forward() is the thing that adds 1 to the count. If an agent built a brand-new envelope instead of forwarding the one it received, the count would start at zero every time and the ceiling would never be reached. That is not a hypothetical: it is how this shipped for one release, and the exercise at the end of this lesson has you reproduce it.
The skill below traces how a message propagates between peers and what survives each hop — B1.7 runs it to follow an injection; you are running it to see what your envelope actually preserves.
The skill — skills/threats/peer-message-propagation-trace/SKILL.md
name: peer-message-propagation-trace
description: >-
Follow a payload read by one agent as it becomes a message to its peers, and
record how many agents act on it and where the source attribution is lost.
Use when reviewing agent-to-agent messaging, hand-offs, or any topology where
one agent summarises for another.
allowed-tools: Read, Grep, GlobOne agent reads a poisoned document. What spreads is not the document — it is the agent's summary of it, and summarising is exactly the operation that drops the sentence identifying where the claim came from. By the second hop the claim has no source and looks like a colleague's conclusion.
Any multi-agent topology: an orchestrator with workers, a pipeline of specialists, agents that publish to a shared channel or queue.
1 — Draw the topology as edges. Who can send to whom, and whether messages fan out. Include the shared surfaces — a queue, a board, a memory both read — because they are edges too.
2 — Mark a payload so it is traceable. Include a phrase that identifies the source. It will let you see the hop where attribution is lost, which is the finding.
3 — Inject at one agent and step the topology. After each hop record: which agent holds it, whether the marker survived, and whether the agent acted.
4 — Count actors, not hops. The number that matters is how many agents took an action on a claim that entered through one document. One is a bug; three is a topology problem.
5 — Ask what the receiver could have checked. If a peer message carries no origin, the receiving agent has no way to treat it as untrusted, and the fix is on the sender's format rather than on the receiver's judgement.
Input — the fixture committed at the top of scripts/peer_message_propagation_trace.py. Edit it and re-run: the buckets, counts and verdicts below are derived from it, not hard-coded.
Output — the opening lines of a real run:
one poisoned document, summarised by pricing-agent, sent to its peers:
pricing-agent applied 90% discount
billing-agent applied 90% discount
agents that acted on it: 2
agents actually attacked : 1
The run continues past this. test_skills.py executes the script on every build, with no model configured — so what CI proves is that it runs and refuses legibly, not that it prints these lines. The output above is a recorded run against a real model, and nothing re-diffs it: a model's answer is not reproducible, and this repository does not claim otherwise anywhere it can be checked.
{
"topology": {"edges": [["str", "str"]], "shared_surfaces": ["str"]},
"hops": [{"agent": "str", "marker_present": true, "acted": false}],
"actors": 0,
"attribution_lost_at": "str",
"message_format": {"carries_origin": false}
}
Three ways to do this lesson. Pick one. They run the same procedure against the same code — what changes is who drives it, and how much you see.
The agent reads the lesson, explains the idea, fetches the code, runs the check and answers your questions as you go. This is the one to take if you are here to learn.
Install it, once. From the folder you cloned. --all does every agent it finds on your machine:
python3 scripts/install_skills.py --all --lessons A1.6Then restart your agent and open it in that folder. On Windows, if python3 says Python was not found, use python instead.
Then pick a1-6-agent-to-agent:
| agent | install just for this agent | start it |
|---|---|---|
| Claude Code | python3 scripts/install_skills.py --tool claude --lessons A1.6 | type /a1-6-agent-to-agent |
| GitHub Copilot (VS Code) | python3 scripts/install_skills.py --tool copilot --lessons A1.6 | type /a1-6-agent-to-agent in Chat |
| Cursor | python3 scripts/install_skills.py --tool cursor --lessons A1.6 | type / in Agent chat and search for it |
| Codex | python3 scripts/install_skills.py --tool agents --lessons A1.6 | type $a1-6-agent-to-agent, or run /skills |
No agent, no conversation. The same lesson executes and prints the same readback. Take this one if you have no assistant, if you are reviewing rather than learning, or if you want the result without the teaching around it.
python3 scripts/lesson.py A1.6Neither of the above, and no model needed. The first command writes CyberTravels exactly as it stood at the end of this lesson; the second prints the diff — the lines this lesson added or changed, and nothing from any later one.
python3 scripts/checkpoint.py --at A1.6 --out work/cybertravels
python3 scripts/checkpoint.py --at A1.6 --diffOn Windows, type python wherever these say python3 — and use \ instead of / in the paths. Everything else is identical. A0.0 step 2 explains why.
Ways 1 and 2 need a model; way 3 does not. A signed-in Claude Code CLI needs no API key, and any OpenAI-compatible endpoint works too. With neither, the audit exits 2 and says so rather than inventing an answer. A0.0 sets this up.
Ways 1 and 2 both end with the same readback: What I did, What changed, The number and Read this next. A step that was skipped or refused is reported as exactly that, never as a pass.
If you did the last lesson’s challenge, expect this to stop. Most challenges ask you to edit the code, and ways 1 and 2 will not overwrite your edits: you get stopped, not a failure, with two options — --out for a fresh folder that keeps your work, or --force to discard it and start this lesson clean.
A signed envelope naming its sender and the human it acts for, a refusal on a tampered one, a refusal on an envelope with no human in the chain, and — from the loop above — three forwards that each keep Dana and the trace id, then hop ceiling reached (4) on the fourth.
Replace the a2a.forward(...) call in the loop with a fresh a2a.envelope("coding", "workflow", "again", on_behalf_of="dana") and run it again. It never raises: hops prints 0 every time, because a new envelope starts the count over. You have just written the version of this code that has a hop ceiling in it and cannot reach one — which is how the real thing shipped for a release. Then decide what would have caught it, and notice that only a test which forwards for real would have.
Tools used
Skill source: skills/threats/peer-message-propagation-trace