Who is the agent acting for?
An agent answering with the wrong customer’s data does not look like a failure. It looks like it worked.
An agent that answers a question using data from the wrong customer does not look like a failure. It looks like it worked. The response is fluent, relevant, and correct in every respect except the one that matters, which is that the person reading it was never entitled to see it.
That is the property I care about most when someone shows me an agent wired into real data, and it is usually the property nobody has thought about. The question I ask is simple and it is uncomfortable more often than not: who is this agent acting for?
The shape almost everyone builds first
The first version of an agent with data access nearly always looks like this. There is one service account. It can read everything, because during development reading everything is convenient and any narrower permission is a thing you have to stop and configure. Tenant scoping arrives as context: the system prompt says which customer the conversation is about, and the tools accept a customer identifier as an argument.
It works. It demos beautifully. And the authorisation boundary now lives in a string that a language model is being politely asked to respect.
I want to be precise about why that is a problem, because “prompts are unreliable” is a lazy way to put it and invites the equally lazy answer of writing a firmer prompt.
The model cannot refuse what the tool permits
If a tool signature accepts a tenant identifier, then choosing that identifier is part of the model’s job. You have made it a decision. And the model makes that decision from whatever is in its context, which includes the conversation, the retrieved documents, and anything a user typed.
The failure does not require an attacker. Three ordinary things cause it:
- Drift. A long conversation mentions several accounts. The correct identifier for turn twelve is not obviously the one from turn two, and nothing in the loop is tracking which was authoritative.
- Helpfulness. A user asks a comparative question. Answering it well requires data from a second tenant. The model has a tool that can fetch it and no reason to believe it should not.
- Injected content. Any text the agent reads and did not author is a potential instruction. A support ticket, a document, a product description. If reading is enough to influence which identifier gets passed, then whoever writes that content shares control of the boundary.
All three have the same root. The identity of the principal is being derived from content, and content is not a trustworthy source of identity.
Move the decision out of the model
The fix is not a better instruction. It is removing the parameter.
The tool should resolve the principal from the session, server side, and the model should have no way to express a different one. Compare these two signatures:
# the model chooses. the boundary is advisory.
get_invoices(tenant_id: str, status: str) -> list
# the model cannot choose. the boundary is structural.
get_invoices(status: str) -> list
# tenant resolved from the authenticated session, not from arguments
The second signature makes the cross-tenant request unrepresentable. There is no argument to get wrong, no string to be talked into changing, and no prompt to harden. An injected instruction telling the agent to fetch another customer’s invoices produces a tool call that is identical to the correct one, because the tenant was never the model’s to supply.
This is worth being stubborn about, because there is always a use case that seems to need the parameter. An internal admin agent that legitimately spans tenants, for instance. The answer is a separate tool surface with a separate principal, not one surface with a flag, because a flag is exactly the thing that gets set incorrectly.
Three questions worth answering before shipping
What can this principal reach if the model is fully compromised? Assume every instruction in the context window is adversarial and the model does exactly the worst permitted thing. The blast radius is the answer, and it should be the tenant, not the estate.
Can you prove after the fact which principal did what? If every tool call reaches the database as the same service account, your audit log records that the agent did it, which is not useful during an incident. The principal has to be visible at the point of access, not just in the application layer above it.
Have you actually tried? Write the test. Put a second tenant’s identifier in a document the agent will read, ask a question that would be better answered with cross-tenant data, and see what the tool layer receives. This is a cheap test and it is skipped almost universally, because the happy path demos so well that nobody goes looking.
Why this keeps happening
Because it is a genuinely new place for an old bug. We already know not to trust client-supplied identifiers in a web request. Nobody sensible reads a user ID from a query string and returns that user’s data. But an agent’s tool arguments feel like internal function calls rather than untrusted input, and they are treated accordingly.
They are not internal. They are values produced by a process that consumed untrusted text. The moment you look at them that way, the design follows on its own, and the prompt goes back to being what it should have been: a description of how to be useful, carrying no security weight at all.