Three Fences on the Way In
You can assume the agent is hostile and hand it only a small set of capabilities, and it will still try to read the wrong tenant's row. The fix is not a better prompt. It is three fences that sit below the agent, on the way in, none of which trust it.
I spent two posts arguing that you should treat the agent as hostile and give it only a small, fixed set of capabilities. That holds. But there is a gap it does not close on its own, and I want to show you the gap before I show you the fix.
Here it is. Even a correctly scoped agent, asked the right way, will try to read another tenant’s row. Not because the scoping was wrong. Because somewhere below the agent there is a query, and the agent got to help write it.
The pothole
The classic version is one line of SQL:
SELECT ... FROM records WHERE tenant_id = {the value the agent supplied}
If the agent controls that filter, then injection controls that filter. A convincing enough message, a poisoned document the agent read, a tool description it misread, and the value it passes for tenant_id is no longer its own. The agent did not break out of anything. It used a door I left open, the way I built it to be used.
So the question is not “how do I make the agent ask correctly.” I already decided I can’t trust the asking. The question is where to put the walls so that asking, even asking perfectly, lands against something that does not care.
Below the agent. On the way in. Three of them.
Fence one: a service account per tenant
The first fence is the identity the code runs as.
Each tenant gets its own cloud service account, with IAM bound to only that tenant’s resources. Its dataset. Its bucket prefix. Its secrets. Nothing else. No shared god-account that can reach everything, ever.
The reason is blast radius. A shared account means the only thing standing between tenants is the application layer getting every query right, and if that one layer fails, the breach is every tenant at once. A per-tenant account means one successful injection is one tenant’s problem, and the IAM audit log names exactly whose. You have turned a catastrophe into an incident.
How clean the boundary is depends on your layout, and it is worth being honest about that. Where a client is its own cloud project, the service account boundary and the project boundary are the same line, and that is the strongest form. Where instances share a project, IAM conditions and per-dataset bindings keep the accounts disjoint. For the highest-stakes perimeters there is VPC Service Controls. Different strengths, same idea: cap what the agent can touch at the identity layer, below the application, where no sentence in the prompt reaches.
An honest caveat on scale. A service account per tenant is the clean form when tenants are siloed, a few or a few hundred, each with real resources of its own. It is not how you’d isolate a million-tenant pooled table, where you’d run under one identity and lean on dynamic, per-request scoping and row-level security to draw the line instead. Same goal, a boundary the agent cannot cross, reached at whichever layer your tenancy actually lives.
Fence two: the agent never supplies the filter
The second fence is the one that closes the pothole directly.
The agent does not pass the tenant id. It cannot. There is no parameter on the tool for “which tenant.” The harness reads the tenant from the authenticated session context and injects it into the query itself. The agent’s tool call cannot specify it, cannot override it, cannot omit it. The words for “some other tenant” are not in its vocabulary.
This lives in the data-access layer, a scoped repository or query builder that every read goes through. Not in a tool’s docstring. Not in an instruction that says please only query your own data. A docstring is advice. The scoped repository is the thing that actually runs, and it only knows how to build a query for the caller it was handed.
Row-level security at the database is the belt to this layer’s suspenders. Postgres RLS, for example, set up right, with the tenant context fixed outside the agent’s reach and the app role unable to bypass it, will enforce the tenant boundary even if a query somehow gets built wrong, because the policy sits under the query, at the table. Two independent layers that both have to fail before a row crosses the line.
Fence three: reject before you read
The third fence is at the tool itself, and it runs before any data is touched.
This is two checks, and it is worth keeping them separate, because they catch different attacks. The first is shape. I lean on typed boundary models for this, Pydantic v2, extra="forbid". Parse the input once at the edge, and from there inward the code trusts a typed object instead of a loose bag of strings. A parameter the tool did not define is not a thing to interpret. It is a rejection.
But shape is not scope. Fence two took the tenant id out of the agent’s hands, so the request that gets through now is the well-formed one: a valid record id, a real file key, a project id that parses fine and belongs to someone else. Pydantic will not catch that, because nothing is malformed. So the second check is authorization, and it is its own step: does this identifier actually belong to the caller’s session? The tool asks that question against every object id it was handed, before it reads a thing, and rejects the ones that fail.
The reframe that matters: “the agent asked for a parameter it should not have” is a rejected tool call, logged as a security event. It is not a judgment I ask the model to make. I do not want the agent deciding whether a request is in scope, because deciding is exactly the thing an attacker gets to influence. The tool decides, mechanically, and writes down that it happened.
Writes get the strictest version of this. Anything that changes state routes through the write contract, with its conditions, its approval queue, its mutation log. The harness makes that path non-bypassable rather than re-implementing it inside each tool and hoping every tool got it right. One gate, enforced once, that every write has to go through.
Why this holds
Line the three up and look at what an attacker actually has to beat.
Persuade the agent perfectly, and it still runs under a service account that cannot see another tenant’s data. Get past that somehow, and it cannot supply the filter that would cross the line, because it has no parameter for it and RLS sits under the query besides. Get past that, and the tool rejects the out-of-scope call before it reads anything and logs the attempt.
The agent being compromised never breaches a fence, because none of the three trust the agent. They were built for a hostile caller from the start. That is the whole point of putting them below the conversation instead of inside it. Instructions advise. Fences enforce.
The way out is its own story
These three guard the way in. There is a direction I have not touched here, and it is the one people forget: the way out. What the agent sends back, the response it composes, is its own surface, and a scoped read does not protect you if the answer leaks something on the way to the user. That is the next piece.
For now the shape is enough. You do not make the agent trustworthy enough to hand it the filter. You take the filter out of its hands, three times over, below the place where it can be talked into anything.