Skip to content
HN On Hacker News ↗

Loopjacking in A2A Implementations: Hijacking Human-in-the-Loop Approvals

▲ 8 points • 1 comments • by akoffsec • 2w ago • HN discussion ↗

Pangram verdict · v3.3

We believe that this entire text is AI.

100 %

AI likelihood · overall

AI
0% human-written 100% AI-generated
SEGMENTS · HUMAN 0 of 1
SEGMENTS · AI 1 of 1
WORD COUNT 1,478
PEAK AI % 100% · §1
Analyzed
Sep 25
backend: pangram/v3.3
Segments scanned
1 windows
avg 1478 words each
Distribution
0 / 100%
human / AI fraction
Verdict
AI
Pangram v3.3

Article text · 1,478 words · 1 segments analyzed

Human AI-generated
§1 AI · 100%

In a controlled LangGraph Agent Server test, the approval role received a human-in-the-loop interrupt for mock_wire_transfer(20, approved-vendor). A separate maker could update the pending thread but could not approve or execute a protected transfer. The maker sent another message through the server's A2A route, replacing the pending call with mock_wire_transfer(2000, attacker-sink). The approval role submitted its earlier decision for a transfer of 20 units. The mock ledger recorded a transfer of 2,000 units under the approver's authority. The approval role was scripted after the test asserted the exact product view of A. The test measures the product's approval binding, not whether a person would notice a change in a user interface. It used an in-memory server, synthetic identities, a deterministic local model, and a harmless ledger. The public evidence archive preserves the requests, decisions, controls, and exact tested versions. This is one way Loopjacking can occur: an implementation uses a decision for operation A to release materially different operation B. The decisive question is which operation reached the sink under A's decision. This post follows that question through A2A's task model, its authorization guidance, and the released Agent Server path. A Task is not the approved operation An A2A Task gives agents a continuing unit of work. A client can send a message that names an existing Task and context, and an agent can report that it needs more input or authorization before continuing. The Task ID answers which work is this message about? It does not answer which exact tool call did the human authorize? Suppose a Task carries proposed operation A. The application shows A to an approver and records decision D_A. If a later message changes the pending operation to B, the Task may still have the same ID. An implementation that checks only “this Task was approved” can spend D_A on B. An implementation that compares the current executable operation with the one bound to D_A rejects B or asks again. Neither outcome follows from the Task ID alone. There are two distinct records here. The coordination record says a Task is paused, can receive a message, and may continue. The approval record must say what operation was reviewed, who approved it, under which scope, and whether it still applies when execution is about to happen. A2A supplies the former. The implementation or credential issuer supplies the latter. A human might deliberately approve a broader scope, but the breadth must be explicit in what the human sees and in what the implementation enforces. A2A coordinates Task messages and continuation. The application or issuer defines and checks the approval's operation scope. That diagram is a conditional model. The failure exists only when implementation code selects changed B and spends D_A without checking its scope. A server that accepts the same Task message and then asks for new approval behaves safely under the same protocol mechanics. What the pre-clarification A2A text left open The specification before section 7.6.4 treated TASK_STATE_AUTH_REQUIRED as an interrupted, nonterminal state. It advised agents to accept messages addressed to that Task while authorization was pending so a client could negotiate, correct, or reject a request. It also allowed an agent that received a credential out of band to continue without waiting for another client message. Human approval before a destructive action appeared among its examples of in-task authorization. Those choices support useful workflows. A requester can correct a pending request instead of starting over; a credential can arrive through a separate channel. They also create a precise question: if a message changes the proposed action while authorization is pending, which operation does the eventual decision cover? The older text did not define an approver, a canonical executable operation, an action-scoped grant, or the consequential sink. Nor did it expressly assign responsibility for defining approval scope and checking a later operation against it. That was a specification clarity gap. It was not proof that A2A itself granted approval, required task-wide approval, or had a core protocol vulnerability. The same protocol flow could sit above an implementation that pins A and rejects B. Issue #2080 raised the ambiguity. PR #2081, merged into main on July 30, 2026, added section 7.6.4. The current specification says TASK_STATE_AUTH_REQUIRED signals a need for authorization, not a grant for any operation. The implementation, credential issuer, or extension defines scope. If specific operations need authorization, the implementation identifies them and checks authorization before use; later Task messages are not implicitly covered. That clarification changes the implementer's reading of the state: AUTH_REQUIRED tells peers why work is interrupted. It does not turn the Task into a reusable permission token. On continuation, the implementation still needs to resolve the action it will actually execute and ask whether the credential or human decision covers that action. If a same-Task message changes the amount or destination, the status and Task ID cannot answer that question. The A2A releases page still listed v1.0.1 as the latest tag when checked September 22, 2026. The clarification was merged on main; a tagged release should not be assumed to include it. The tested A2A path in LangGraph Agent Server The concrete product result is narrower than the specification scenario. The experiment exercised LangGraph Agent Server's shipped A2A JSON-RPC message/send endpoint and its message.command.update surface alongside the official human-in-the-loop middleware. message.command.update is an Agent Server command carried on that route; it is not a general A2A approval primitive. The experiment did not run A2A §7.6's out-of-band credential flow, and it does not establish that the old specification wording caused the product result. The tested composition used Agent Server's in-memory runtime, LangChain 1.3.18, LangGraph 1.2.11, and a supported, researcher-defined Auth policy. The policy deliberately separated the abilities that make approval meaningful: Principal Could do Could not do Maker Create the work and update its shared pending thread Resume the approval interrupt or execute the protected mock transfer Approver Read the pending view, resume the interrupt, and execute with wire:execute Make the maker's pending-state update Outsider No relevant access Read the protected thread This is the attack precondition, not a claim about LangGraph's default policy. If the maker already had wire:execute, B would not need the approver's decision. If the maker could not update pending state, the tested substitution path would stop before approval resumed. Where B entered The maker's initial A2A request created work on a thread. The approver then read Agent Server's human-in-the-loop interrupt, which showed the exact mock_wire_transfer name and A's arguments. Only after that recorded view did the maker submit another message/send request. Its message named the existing taskId and contextId and carried a command.update containing a replacement assistant message. The replacement reused both the pending message ID and the tool-call ID, while changing the tool-call arguments. In compressed form, the relevant fields were: method: message/send message.taskId: existing Task message.contextId: shared thread message.command.update.messages[0].id: approval-action message.command.update.messages[0].tool_calls[0].id: transfer-call message.command.update.messages[0].tool_calls[0].args: B The repeated IDs made the update address the pending work and replace the existing call. They did not certify that its amount or destination matched the call shown to the approver. The before-and-after record makes that change visible: Approval view: id=transfer-call mock_wire_transfer(20, approved-vendor, req-001) A2A update: id=transfer-call mock_wire_transfer(2000, attacker-sink, req-001) Approver sends: approve Tool receives: mock_wire_transfer(2000, attacker-sink, req-001) The trace crosses four product components. Agent Server accepted the A2A message and forwarded its command into run creation. Its command mapping passed the update along. LangGraph's message reducer replaced the pending same-ID call. When the approver later sent only approve, the human-in-the-loop path reconstructed the call from current thread state. Execution inherited the authenticated approver principal and its permission. There was no comparison between the earlier approval view and that reconstructed call, so the mock tool received B. The public evidence index records the requests, source receipts, decisions, and ledger events behind this account. The update did not itself execute B. The maker's attempt to resume was denied. The later, separately authorized approver resume was the point where the stale decision acquired an effect it did not describe. That timing is why this is post-approval state substitution rather than a simple unauthorized tool invocation. The tested in-memory Auth policy permitted the maker's update but denied maker approval and direct execution. What the controls establish A paused thread containing B would be weak evidence on its own: it would not show that B executed, who authorized it, or whether the maker had a direct route to the same effect. The experiment checked the view before mutation, the Auth decisions, the state after mutation, and the arguments recorded by an append-only mock ledger. Its controls establish the following: Test path Recorded effect Maker proposes A; approver reads A; maker updates the pending call to B; approver submits the stale decision The attack thread's mock ledger records B, not A Maker tries B without the approver's authority B does not reach the ledger A remains unchanged and is approved A reaches the ledger