handoff series · exploit 08 of 08 · finale

A8 Capability over claim (authN ≠ authZ)

The card is genuinely signed by a trusted issuer authentication passes but it claims a capability it was never granted. A signature proves who published a card; it says nothing about what that partner may do.

This is the subtle one, and the reason A7 and A8 are two exploits and not one. A7 stops a forged card at the door. A8 is what walks straight through a door you just correctly locked. The card is genuinely signed by a trusted issuer authentication passes but it claims a db_admin capability it was never granted. A signature proves who published a card; it says nothing about what that partner may do.

Build
flip it the console below switches between exploited and blocked

What the buttons do: click VULN and every console below shows the attack succeeding on the vulnerable build; click FIXED and the same consoles show it blocked on the hardened build. It switches in place nothing reloads, so you can flip back and forth and compare the exact same step exploited vs. defended.

the seamBoundary ❾ authorization, after authentication

A8 sits one step past A7 on the same federation boundary. Onboarding now correctly verifies the signature so identity is proven and then makes the fatal shortcut of trusting the card's self declared capabilities as if a proven identity implied proven permissions. It doesn't. Authentication and authorization are different questions with different answers, and conflating them means any partner a trusted issuer will sign for can write itself an admin grant.

the payloadA signed card that asks for too much

On VULN the self declared caps are trusted and the table falls out. On FIXED the signature verifies, the partner onboards but its capabilities are clamped to a locally configured grant (account_read only), so the db_admin claim evaporates and the tool call is denied downstream:

A8 · VULN · exploited
[attacker] federate signed-but-greedy card: ok=True (caps=['account_read','db_admin'])
[FraudCheckPartner] tool run_sql('SELECT * FROM users') -> SQL_RESULT(rows=3): {...}
A8 · FIXED · blocked
[attacker] federate signed-but-greedy card:
   ok=True (federated caps=['account_read'])   # signature valid, db_admin CLAMPED
[FraudCheckPartner] tool run_sql('SELECT * FROM users')
   -> TOOL_DENIED: 'FraudCheckPartner' lacks capability 'db_admin'

the fixClamp the caps and enforce the clamp

A8 is the instructive exploit of the whole series because it needs two controls, not one. Verifying the card (A7's control) lets the partner in; clamping its declared caps to a local grant strips the over claim; and per agent capability enforcement (A3's control) is what actually denies the tool call downstream. Drop either link and A8 is back:

verify_agent_cards clamp declared caps to a local grant
# signature already verified (A7). Now authorize, separately:
granted = LOCAL_PARTNER_GRANTS.get(card.name, set())   # {'account_read'}
partner.capabilities = set(card.declared_caps) & granted   # db_admin dropped
# and A3's enforce_tool_capabilities denies run_sql at the call site

threat modelA8, seen from above

Asset at riskCustomer PII the whole table. Property lost: confidentiality.
STRIDE categoryEElevation of privilege a partner over claims capabilities it was never granted and reaches a privileged tool.
Trust boundary federation the authorization half, which survives even a correct authentication.
Adversary & reachA malicious or over eager partner org that a trusted issuer will genuinely sign for. This is most supply chain compromise, exactly: the attacker is inside the trust chain.
Attack tree branchA8 is the third leaf of "dump the whole table" alongside A3 (internal) and A7 (forged identity) reached by over claiming inside a valid identity.

the finaleAll eight, seen from above

A8 is the last exploit, so this is where the series steps back from stories to a systematic view. We walked eight exploits one at a time; a threat model is those same eight seen from above a sweep by category, so you can be sure you didn't miss a ninth. That sweep is STRIDE: the six things that go wrong anywhere, applied to an agent mesh.

CategoryIn an agent mesh, that looks like…BoundaryExploits
SSpoofingimpersonate a privileged agent; forge a partner's identityA4 · A7
TTamperingmutate shared context; tamper the routing decision; poison retrieved state❻ ❼ ❽A1 · A2 · A5 · A6
RRepudiationa rogue hop with no authenticated edge log to attribute it tonone (design gap)
IInfo disclosureread another customer's SSN, or the secrets file❺ ❼ ❽ ❾A1 · A3 · A4 · A5 · A7 · A8
DDenial of servicea delegation loop → unbounded hops and model spend❻ ❽A6
EElevation of priv.a low tier agent runs a high tier tool; a partner over claims caps❺ ❾A2 · A3 · A7 · A8

Repudiation is the row with no exploit and finding that gap is the whole reason to run STRIDE by hand. The lab never demonstrates it, but the threat is real and the fix is cheap: log every handoff as an authenticated edge who authorized this hop so a rogue route can be attributed and alerted on. It's the row you'd have skipped if you only worked backward from the exploits you already knew.

One asset, many paths the attack tree

Threats aren't independent: several exploits reach the same prize by different routes. Draw the tree for the crown jewel and the case for defense in depth writes itself.

attack tree · exfiltrate u9999's SSN
GOAL  read another customer's SSN  (TreasuryOps, u9999)
  │
  ├─ OR ─ escalate my own role to admin, then read the row
  │        ├─ A1  plant [[SETROLE:admin]] in carried notes   (direct)
  │        └─ A5  poison a KB article the peer retrieves     (indirect)
  │
  └─ OR ─ dump the whole table, bypassing per-row gating
           ├─ A3  coerce run_sql() from a zero-capability agent
           ├─ A7  forge a partner card that self-grants db_admin
           └─ A8  over-claim db_admin in a validly signed card  <-- YOU ARE HERE

Five leaves, one goal. A point fix on any single branch say, an input filter that catches [[SETROLE:admin]] leaves the other four wide open. That is the entire case for structural controls: cut the tree at the trunk (the five boundaries), not the leaves.

the answerWhich control is load bearing?

Knock out one FIXED control at a time and re run all eight attacks. A cell lights up (X) where removing that one control lets that attack through. A column with a single X hangs on one control; a column that stays dark is defended in depth.

control removed ↓A1A2A3A4A5A6A7A8
honor_untrusted (provenance)·······
enforce_handoff_allowlist········
scrub_context_on_handoff········
enforce_tool_capabilities·······
authed_registry·······
detect_handoff_loops········
verify_agent_cards······

A1, A4, and A7 each hang on a single control. A2, A3, A5, and A6 survive any single removal real defense in depth. And A8 is the instructive one: it needs both card verification and capability enforcement, because clamping a partner's over claimed caps only helps if those caps are then actually enforced downstream. Authenticate, authorize, enforce three links of one chain, and A8 breaks if you drop any of them.

underneathEight exploits, three ideas

Stare at the seven named controls and they collapse into three root ideas the whole series in three sentences.

Root control I

Data is not instructions

Never act on control content that arrived in untrusted text the user message, the carried notes, and documents a peer retrieved. Enforced by provenance, not pattern matching, so it holds against any wording. Kills A1, A2's payload, A3, A5, and A6's trigger.

Root control II

An explicit, least privilege agent graph

Handoffs follow an allow list, not free form text. Each agent holds only the capabilities it needs. The registry is authenticated. A directed edge can't be traversed twice, so cycles can't form. This is A2, A3, A4 and A6's belt.

Root control III

Authenticate identity and authorize capability separately

Across an org boundary, verify a partner's Agent Card is signed by a known issuer before you trust it (A7) then clamp what it declared it can do to a grant you configured, because a signature proves who signed, not what they may do (A8). Identity is not permission.

Every control here is content independent, which is exactly why the measured block rate is 100% while even a flawless de obfuscating input filter tops out at 43% (and a byte level one at 12%). Provenance never reads the wording; an allow list doesn't care how politely you ask for a forbidden hop; a capability clamp doesn't care how a partner phrased its over claim.

in the wildWhere A8's seam lives in real frameworks

Google A2A & MCP

After verifying a partner's signed card, clamp its declared capabilities to a grant you configured a valid signature is authentication, not authorization. Then enforce that grant at the tool, so the clamp actually bites.

OAuth scopes / API keys

The same shape you already know: an authenticated caller is not an authorized one. Grant the least scope, server side, and never let the client's self declared scope be the source of truth.

Any capability based delegation

Whenever one component tells another what it's allowed to do, that claim must be checked against a locally held policy never accepted because the claimant authenticated successfully.

catching it in prod

Log the declared caps, the local grant, and the clamped result for every federation. Alert whenever a partner's declared capabilities exceed its grant that over claim is either a misconfiguration or an attack, and both are worth a page. Downstream, alert on any tool denial attributed to a clamped capability.

the takeawayAudit the handoff like a network boundary

Because that's what it is a trust boundary between two privilege domains, with an untrusted payload crossing it. Four questions catch every exploit in this series:

  • What does the downstream agent trust from the upstream one?If it's "everything in the shared context," you have A1 and A5.
  • Who decides the next hop, and against what list?If a model reading untrusted text decides with no allow list behind it, you have A2 and A6.
  • Is retrieved content treated as data or as instructions?If a knowledge agent's output flows into another agent's context unquarantined, you have the whole indirect injection class A5.
  • When a partner authenticates, do you also authorize it?If a signed Agent Card's self declared capabilities are trusted as is, you have A7 and A8. Identity is not permission.

Multi agent systems don't fail because any single agent is dumb. They fail at the seams between agents, where each one assumes the other did the checking. The handoff is the soft joint. Weld it.