Your Authz Checks the Caller. The Model Picked the Tenant. A developer demonstrated that AI agents can leak cross-tenant data when authorization checks verify the caller but ignore model-authored resource selectors like tenant_id. In tests, 4 of 5 selectors returned another tenant's rows without a provenance gate, and 0 of 5 did with it. The fix is a pre-execution provenance gate that refuses model-written selectors before any read. A confused deputy in an AI agent is not a broken authorization check. It is an authz check aimed at the wrong operand: it verifies the caller, never the model-authored tenant id selecting the resource. A pre-execution provenance gate refuses model-authored selectors before any read. Without it, 4 of 5 selectors returned another tenant's rows; with it, 0 of 5 did. Your agent is authorized to read invoices. This morning it read a different company's invoices, and every authorization check returned yes. Nobody bypassed the gate. The caller was who it claimed to be, the token was valid, the role allowed the tool. The gate answered the exact question it was built to answer, correctly. The leak lived in an argument the gate never looked at: the tenant id that selects which company's rows come back. And that argument was written by the model. In short: tenant id , account id , project id , an authorized caller can reach another tenant's data. The identity check passes the whole time. tenant id that came from the authenticated session is fine. The same value, if the model wrote it, is not, because the model picking the right tenant once is luck, not authorization. AI disclosure:I wrote scope provenance gate.py with an AI assistant and ran it myself, three times, on Python 3.13.5, standard library only, no network, no keys. Every output block below is pasted from that run. The STDOUT is byte-for-byte identical across the three runs; its sha256 is 63adbe8ebe17e873cbf7dbdf24faed392f20a3205d50579aec1705cf3c7841cb and bash run all.sh reproduces it. The fixture is synthetic and calibrated to nothing: the tenants, invoices and sessions are invented to isolate one mechanism. bot2 is a new project. It has no production fleet and no incident to sell you. This post demonstrates how a bug is reachable, not how often it happens in the wild. The one verbatim external quote the definition of confused deputy is attributed and linked; the practitioner posts I reference are their words, and I link the primary sources. Here is the shape of a normal agent tool call. The agent has a tool, read account rows account id, limit . A request comes in on an authenticated session. Middleware checks whether this caller, in this role, may invoke this tool. It may. The tool runs. Rows come back. Now look at where account id came from. In an LLM agent the tool-call arguments are assembled from two very different sources. Some fields the runtime injects: the session, the auth context, anything you copy in from the request you already trusted. The rest the model fills, from its plan. limit is a fine thing for the model to choose. account id decides whose data you return. If the model writes that field, then the argument that selects the resource is authored by the least trusted component in the system, and the authorization layer never reads it. That is a textbook confused deputy. The Wikipedia article on the confused deputy problem https://en.wikipedia.org/wiki/Confused deputy problem defines it in one sentence: "In information security, a confused deputy is a computer program that is tricked by another program with fewer privileges or less rights into misusing its authority on the system." The term is Norm Hardy's, from his 1988 ACM SIGOPS paper of the same name. The deputy here is your authz middleware. It holds real authority it can read any account and it is tricked into using that authority on a target chosen by the model, because it checks the caller and not the selector. The privilege gap is exact: the model has no standing to read account B, the middleware does, and the middleware acts on the model's choice. So here is the claim, stated so you can break it: an authorization layer that verifies the caller and reads the resource-selecting argument from the model's output can return a resource the caller was never scoped to. The fix does not need a smarter authz check on identity. It needs a check on a different operand: the provenance of the selector. Show me a model-authored account id that reaches data through the gate below, or a session-derived one the gate refuses, and the tool is broken and the claim with it. The fixture is three accounts and two sessions. acct apex and acct ceres and acct borealis , each with one or two invoice rows. Two authenticated sessions: S1 , scoped to acct apex only; S2 , a shared-ops user scoped to both acct apex and acct ceres . Nobody is scoped to acct borealis here, which makes it the clean victim. BLOCK 1 -- the world synthetic, calibrated to nothing acct apex: rows apex-inv-2201,apex-inv-2202 acct borealis: rows bor-inv-5501,bor-inv-5502 acct ceres: rows cer-inv-8801 session S1: identity=user apex ops role=reader authorized accounts= 'acct apex' session S2: identity=user shared ops role=reader authorized accounts= 'acct apex', 'acct ceres' The authorization middleware is the ordinary kind. It answers one question and it answers it right: python def authz allow caller session, tool : """Answers exactly one question: may this caller invoke this tool? It never sees account id.""" return tool in ROLE TOOLS.get session.role, set And the tool trusts that the middleware already did its job, so it does no ownership check of its own: python def run tool tool, values : if tool == "read account rows": account id = values "account id" limit = values "limit" return list DB.get account id, :limit raise ValueError "unknown tool" Neither layer checks who is allowed to read account id . That is the whole bug, and it is boring, which is why it ships. Each argument in the model carries a provenance tag: session if the runtime injected it from the authenticated context, model if it came out of the plan. This is not something I invented for the demo. Your runtime already knows which fields it injected and which the model filled, because you wrote the code that assembles the call. The tag just names a fact you are throwing away. Run the eight calls through the current path: identity check, then the tool. No scope check anywhere. BLOCK 2 -- every enumerated call, WITHOUT the gate call sess selector account id prov id ok foreign rows returned C1 S1 acct apex session True False apex-inv-2201,apex-inv-2202 C2 S1 acct borealis model True True bor-inv-5501,bor-inv-5502 C3 S1 acct borealis model True True bor-inv-5501,bor-inv-5502 C4 S1 acct apex model True False apex-inv-2201,apex-inv-2202 C5 S1 acct ceres model True True cer-inv-8801 C6 S2 acct ceres session True False cer-inv-8801 C7 S2 acct borealis model True True bor-inv-5501,bor-inv-5502 C8 S1 acct apex session True False apex-inv-2201,apex-inv-2202 Cross-tenant reads achieved without the gate: 4 - C2,C3,C5,C7 Read the id ok column. It is True on every row, including the four that leaked. The identity check never failed. C2 and C3 are S1 , scoped to apex, walking out with bor-inv-5501 and bor-inv-5502 , borealis rows, because the model wrote account id = "acct borealis" and the middleware only ever asked whether S1 may call the tool. C5 is S1 reading ceres. C7 is S2 reading borealis, an account it was never scoped to. That is the confused deputy in one screen. The gate the system trusts did not break. It answered "may this caller use this tool?" with a correct yes, while a different operand it never inspected decided the answer to a question nobody asked: "may this caller read this account?" The fix keys on the one property that separates C1 from C4. Look at those two rows again: same session S1 , same selector value acct apex , same rows on disk. C1 is safe and C4 is not, and the only difference between them is who wrote the account id . C1's came from the session. C4's came from the model. The value is identical. The provenance is not. So the rule is provenance, and only provenance: python def scope provenance gate call : """Looks ONLY at the source of each resource-selecting argument, never at its value. Fails closed on unknown tools or params.""" schema = TOOL SCHEMA.get call.tool if schema is None: return False, "DENY: unknown tool, no schema fail-closed " for param, arg in call.args.items : pspec = schema "params" .get param if pspec is None: return False, f"DENY: undeclared param '{param}' fail-closed " if pspec "resource selecting" and arg.provenance = "session": return False, f"DENY: resource-selecting arg '{param}' is " f"{arg.provenance}-authored, must be session-derived" return True, "ALLOW: all resource-selecting args are session-derived" A tiny schema declares which parameter selects a resource account id yes, limit no . The gate runs before authz and before the database. Same eight calls: BLOCK 3 -- the same calls, WITH the provenance gate call selector prov verdict rows / reason C1 acct apex session ALLOW rows apex-inv-2201,apex-inv-2202 C2 acct borealis model DENY DENY: resource-selecting arg 'account id' is model-authored, must be session-derived C3 acct borealis model DENY DENY: resource-selecting arg 'account id' is model-authored, must be session-derived C4 acct apex model DENY DENY: resource-selecting arg 'account id' is model-authored, must be session-derived C5 acct ceres model DENY DENY: resource-selecting arg 'account id' is model-authored, must be session-derived C6 acct ceres session ALLOW rows cer-inv-8801 C7 acct borealis model DENY DENY: resource-selecting arg 'account id' is model-authored, must be session-derived C8 acct apex session ALLOW rows apex-inv-2201,apex-inv-2202 C4 is the row I want you to sit with. Its value was harmless. acct apex is exactly what S1 is allowed to read, and the model happened to name it correctly. The gate denies it anyway. That is not the gate being dumb. It is the gate refusing to grade the model on whether it guessed right this time, because a component that is allowed to pick the tenant when it guesses right is allowed to pick the tenant, full stop. If you want apex read, the selector comes from the session. The model does not get partial credit for coincidence. Every number I am about to state is a count of the eight rows above. No sample size, no percentage over an invented denominator, no averaging. Count them yourself. BLOCK 4 -- the counts recount them from BLOCK 2 and BLOCK 3 K total enumerated calls : 8 resource selector is session-derived S : 3 - C1,C6,C8 resource selector is model-authored M : 5 - C2,C3,C4,C5,C7 of M, selector points OUTSIDE caller's set : 4 - C2,C3,C5,C7 of M, selector happens to name caller's own acct : 1 - C4 WITHOUT gate: model-authored selectors that returned another tenant's rows : 4 of 5 - C2,C3,C5,C7 WITH gate: model-authored selectors that reached ANY row : 0 of 5 - none WITH gate: session-derived selectors that still returned the caller's authorized rows : 3 of 3 - C1,C6,C8 WITH gate: session-derived selectors denied : 0 of 3 - none Three facts, each a count and not an estimate: The 4-of-5 and 0-of-5 are not measurements of a rate that could come out differently on a bigger fixture. They are properties of the construction. Without the gate, a model-authored selector pointing outside the caller's set will always return foreign rows, because identity-only authz always passes and the tool always reads whatever account id it is handed. With the gate, a model-authored resource selector is always denied, because that is the rule. Grow the fixture to eighty calls or eight hundred and the two structural facts do not move. Only the sizes of the sets do. A gate that only ever says DENY would pass the leak test and be worthless. So the tool ships four falsifiers, each of which fails loudly if the gate is the wrong shape: BLOCK 6 -- falsifiers each could fail if the gate were wrong PASS F1 gate does not break legitimate session-derived scope 3/3 session-derived calls served, 0 denied. A deny-all gate would fail this. PASS F2 classifier reads the source of the arg, not its value C1 and C4 both select 'acct apex'; C1 session ALLOW, C4 model DENY. A value-based check account id == self would ALLOW C4 and miss the rule. PASS F3 session-derived scope leaks nothing even without the gate All session-derived selectors are inside the caller's authorized set by construction, so identity-only authz already returns own rows. The vulnerability is specific to model-authored scope, not to the tool. PASS F4 gate keys on the resource-selecting operand, not on 'any model arg' C8's limit is model-authored but non-selecting; account id is session-derived. Gate ALLOWs C8. A blanket 'no model input' gate fails. F2 is the one that matters most, because it is the difference between a real fix and a fake one. The obvious patch is a value check: assert account id == session.tenant . That would stop C2, and it would also break S2 reading its second authorized account, and it would silently pass a model-authored value that happens to match. F2 proves the gate is not doing that. C1 and C4 carry the identical value acct apex and get opposite verdicts, so the decision is provably keyed on the source, not the string. F4 proves the gate is not the lazy over-correction either: it does not reject every argument the model touched, only the one that selects a resource. C8's limit is model-authored and the gate lets it through, because a model choosing to fetch fifty rows of accounts it is allowed to see is not a confused-deputy problem. F3 is the honest scoping of the whole claim. The tool is not dangerous. read account rows is fine. The vulnerability is specific to model-authored scope, and F3 shows it: every session-derived call leaks nothing even with no gate at all, because a selector drawn from the session is inside the caller's authorized set by construction. Take the model out of the resource-selection path and there is no deputy to confuse. If you read the write-chain taint post here three weeks ago https://finops.spinov.online/blog/gate-taint-lint/ , this can look like the same story told twice. It is a different operand, and the difference is the whole point. In that post the gate keyed on a signal, sender trust , and the danger was that a model had written some store upstream of that signal, so the value the gate read was model-laundered. The fix walked the write-closure of the signal the gate reads and refused to let a model-tainted signal hold the authorization role. The tainted thing was the thing the gate checks. Here the gate reads a clean signal. Caller identity is world-anchored; the model did not write it and there is no write-hop to trace. The gate is right about what it checks. The bug is that the resource selector is a different argument entirely , a sibling operand the authz layer structurally never inspects, and that operand is model-authored at the point of the call. There is no laundering and no upstream store. The model just fills a field, directly, and the field decides whose data comes back. Taint-linting the signal the gate reads would not catch this, because the signal the gate reads is clean. You have to gate the operand the gate ignores. Same family, adjacent bug, different fix. If your mental model is "make sure the gate's inputs are trustworthy," this one slips past, because the gate's inputs are trustworthy and the leak is in an input the gate never took. The general franchise both posts sit in is the same, and it is the one I keep coming back to: gate before you execute, do not log after https://finops.spinov.online/blog/pre-execution-gate-for-ai-agents/ . It is the same instinct as gating the lethal trifecta before the agent runs https://finops.spinov.online/blog/lethal-trifecta-gate/ and as the post on model-authored SQL reaching the database https://finops.spinov.online/blog/agent-authored-sql-reaches-db/ : an operand an untrusted component authored gets checked before it acts, not logged after. Logging the call tells you which account leaked, next week, in the incident review. Gating the provenance of the selector stops the read. This is a mechanism demo on a synthetic fixture. It shows that the leak is reachable and that a provenance gate closes it on eight enumerated calls. It is not a claim about how common the bug is, not a benchmark, and not a measurement of anything in production, because bot2 has no production. The provenance tag is the load-bearing assumption: the gate is only as good as your runtime's honesty about which arguments it injected versus which the model filled. If you assemble tool calls by letting the model emit the whole JSON and never tracking what you put in, you do not have the tag, and step one is to start attaching it. The gate cannot recover a provenance you never recorded. There is a real cost, and C4 is it. The gate denies model-authored selectors that would have been harmless, because it refuses to read the value. In exchange you get a rule that does not depend on the model being right. I think that trade is correct for anything that selects a tenant. You may not, for lower-stakes selectors, and F4 is there precisely so you can scope the rule to the arguments that deserve it. Find every tool where an argument selects a resource: tenant id , account id , project id , workspace , user id , org . For each one, ask a single question: at the moment the call is assembled, does that argument come from the session or from the model? If you cannot answer, that is the finding. Start tagging. Practitioners are already converging on this from the other side. Kailash Sankar, in "Defense in Depth: Tenant Isolation for an Agent That Executes Code" https://dev.to/ksankar/defense-in-depth-tenant-isolation-for-an-agent-that-executes-code-375j , wires a proxy that overwrites whatever the model puts in a tenantId parameter with the trusted value from the context registry, hallucinated or injected value be damned. Brian Hall, in "Don't use an LLM to decide what your AI agent is allowed to do" https://dev.to/brianrhall/dont-use-an-llm-to-decide-what-your-ai-agent-is-allowed-to-do-1dkn , puts it as a design rule: the decision on whether a real action runs "has to sit on something that gives the same answer every time and can show its work afterward." Overwrite and deny are two implementations of the same rule, and they differ in one way worth naming. Sankar's proxy silently corrects the model's tenantId ; the model never learns it overreached. The gate here refuses and says why, which turns a silent correction into a visible signal you can count, alert on, and use to notice a plan that keeps reaching for tenants it was not handed. Silent is safer to ship. Loud is better for finding out your agent has been trying the wrong door for a month. I have not run this in anger long enough to tell you which one you will regret less. Pick the one that matches how much you trust your own logging. The tool, all eight calls, both execution paths, the selftest and the four falsifiers are in scope provenance gate.py . Copy it, add a ninth call, watch the counts move by exactly one. I write one runnable tool per post about operating AI agents in production: the cost, the failures, the gates that run before execution instead of the logs that run after. Follow for the next one. And tell me in the comments: in your agent, which tool arguments come from the session and which come from the model, and are you sure?