All lessons › Function A — Getting Started — Building Agentic AI › Build the Agent — From a Loop to a Running Platform
A1.6

Agent to agent — handing work over without laundering authority

A1
▶
Lightboard recording

Not recorded yet. The lesson below is complete and runnable today — the video is added later without touching this page.

Video placeholderpublishes automatically from recordings/A1.6.mp4
Risk

A peer's message read as a colleague's instruction turns one injected document into four compromised agents.

Control

Signed envelopes, the human carried through every hop, and a ceiling on hops.

The lab

Forge a peer message and watch verification refuse it.

◉Use case relevance

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.

At CyberTravels. CyberTravels' four agents hand work to each other. 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.

◴What this lesson is — Day 0, Day 1, Day 2

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.

Day 0 — why

A peer's message read as a colleague's instruction turns one injected document into four compromised agents.

Day 1 — how

Sign every agent-to-agent envelope, carry the human through each hop, and cap the hops.

Day 2 — measure

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 measuredenominator or target
tampered envelopes rejectedshare of tampered envelopes; target 1.00
hop at which a cycle stopshop number; a ceiling you choose, not one you discover

▦The framework, and how it works

        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.

The envelope

Three properties, and each closes one of those:

  • The sender is named, and the naming is signed. "The coding agent asked me to" becomes checkable rather than claimed.
  • The human is carried through. An envelope holds the 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.
  • A peer's text is data. Content is labelled with its origin, exactly as memory is. A peer is precisely as trustworthy as whatever it last read.

And a hop ceiling

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.

2 · Forge a peer message, and watch verification refuse it

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

▶Real time execution as skill

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, Glob

The hand-off is where provenance dies

One 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.

When to use this

Any multi-agent topology: an orchestrator with workers, a pipeline of specialists, agents that publish to a shared channel or queue.

Procedure

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.

Example

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.

Output contract

{
  "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}
}

Failure modes

  • Tracing the document instead of the message. The document stops at hop one; the summary is what travels.
  • Counting hops as the impact. Actors are the impact.
  • Putting the fix on the receiver. A receiver cannot re-derive an origin the sender did not transmit.

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.

1In your AI agent — you are taught

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.6

Then 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:

agentinstall just for this agentstart it
Claude Codepython3 scripts/install_skills.py --tool claude --lessons A1.6type /a1-6-agent-to-agent
GitHub Copilot (VS Code)python3 scripts/install_skills.py --tool copilot --lessons A1.6type /a1-6-agent-to-agent in Chat
Cursorpython3 scripts/install_skills.py --tool cursor --lessons A1.6type / in Agent chat and search for it
Codexpython3 scripts/install_skills.py --tool agents --lessons A1.6type $a1-6-agent-to-agent, or run /skills

2As one Python command — you just run it

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.6

3Read the code, and the difference — nothing runs

Neither 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 --diff

On 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.

✓What you just proved

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.

✎Your turn

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

A2A

Skill source: skills/threats/peer-message-propagation-trace

← PreviousA1.5 · Memory — what it remembers, and where that came from