Summary
This was found in an undisclosed product during bug hunting.
Modern AI applications rarely consist of a single model responding to a single prompt. A user-facing agent and depending on user request may delegate work to a specialized subagent, which then calls tools, queries internal services, and reports its findings back to the main agent.
That architecture introduces a class of failure that is easy to miss during ordinary testing: what happens when a subagent exhausts its turn budget and the harness has to shrink its conversation history?
During testing, I encountered a particularly interesting answer. The subagent harness compacted its context by dropping the oldest message. That message happened to be the original user message ( the main agent is "user" here ). The next inference request therefore contained system instructions, assistant messages, and tool results but no user message.
A strict LLM gateway correctly rejected the malformed conversation. The surrounding application then handled that error incorrectly: it serialized the verbose upstream exception into a server-sent events stream exposed to the client. The interface displayed only a generic failure, but the raw stream contained internal debug information, including sensitive request metadata.
The result was a security issue produced by the interaction of several individually understandable design decisions:
- A main agent delegated a complex task to a subagent.
- The subagent performed enough tool-assisted work to reach its turn limit.
- Context compaction removed the original user message.
- Gateway validation rejected the now-invalid message sequence.
- The main agent api forwarded the raw exception through an attacker-visible SSE response.
No component needed to be explicitly prompted to reveal a secret. The attacker only had to drive the system into a failure state and inspect the protocol beneath the UI.
The architecture
The affected pattern can be represented as four layers:
User
|
v
Main agent / orchestrator
|
v
Tool-using subagent harness
|
v
LLM gateway and internal services
The main agent receives the user's request and decides whether to answer directly or delegate it. For broad or intensive tasks, it invokes a subagent with its own system prompt, tools, transcript, and turn budget.
The subagent may need several inference cycles:
user request
-> assistant tool call
-> tool result
-> assistant tool call
-> tool result
-> ...
-> final answer
Every cycle adds messages to the context. Because model context and application budgets are finite, agent harnesses commonly impose a maximum number of turns or trim older messages as the transcript grows.
The dangerous assumption is that all messages are equally disposable.
Triggering the edge case
A normal question may require only one tool call and never approach the limit. A request for exhaustive discovery, repeated verification, pagination, or a complete schema inventory can behave differently. It encourages the main agent to assign a large task and causes the subagent to continue gathering evidence across many tool turns.
Eventually, the harness reaches a configured limit and applies a policy similar to:
if (messages.length > MAX_MESSAGES) {
messages.shift();
}
This preserves the most recent tool activity, but it does not preserve the semantic structure of the conversation. If the oldest retained item is the originating user request, removing it leaves a transcript resembling:
[
{ "role": "system", "content": "You are a data-query subagent..." },
{ "role": "assistant", "tool_calls": ["..."] },
{ "role": "tool", "content": "..." },
{ "role": "assistant", "tool_calls": ["..."] },
{ "role": "tool", "content": "..." }
]
The transcript still looks locally coherent: the latest tool calls have matching results, and the subagent has plenty of evidence to continue. Globally, however, it has lost the message that explains who requested the work and what the work is anchored to.
Strict validation exposes the broken state
The LLM gateway in this flow required every inference request to contain a user query. That is a sensible invariant. When it received the compacted transcript, it rejected the request with a client error equivalent to:
No user query found in messages
The validation was working as intended. It prevented a system and tool only transcript from being processed as though it were an ordinary user-initiated inference.
The error path becomes the exfiltration path
Upstream client libraries often attach extensive diagnostic state to exceptions. An error object may contain:
- the upstream URL and HTTP method;
- request headers, including bearer tokens or API keys;
- routing, tenant, or session-affinity headers;
- the request body, including system prompts and tool definitions;
- the complete agent transcript and tool outputs;
- the upstream response body and internal request identifiers.
That detail is valuable in trusted server logs. It is dangerous in a client response.
In this case, the main application's streaming error handler effectively did this:
try {
await runAgent();
} catch (error) {
await sendSse("server_error", serialize(error));
}
The visible interface converted the event into a generic message such as “Something went wrong.” This gave the appearance that the failure was safely handled. The browser had already received the verbose event, however, and anyone inspecting the network response could read it.
An SSE event is not private merely because the frontend does not render it. Browser DevTools, an intercepting proxy, or a direct HTTP client can access every byte returned to the client.
The effective data flow was therefore:
Internal exception
-> serialized with request and response diagnostics
-> emitted in client-facing SSE
-> ignored by the UI
-> recovered from the raw network stream
The hidden UI state reduced discoverability, not exposure.
Security impact
The exact impact depends on what the exception captures. Potentially exposed material includes:
- production service credentials;
- private system prompts;
- tool schemas and internal capability descriptions;
- internal hostnames and model routes;
- tenant or session identifiers;
- user data contained in prior tool results;
- provider diagnostics useful for further attacks.