One agent, two teams, one key
Here is a deployment an applied-AI engineer builds often. A customer wants an internal agent that reads and comments on GitHub issues. Two private repositories matter: one for the payments team, one for marketing. The platform team installs one GitHub App for the agent, and the App can read and write both repositories. Ana works on payments. Bo works on marketing. Both talk to the same agent in the same chat.
Bo types, "show me the open issues in the payments repo." Nothing in the plumbing stops the agent from answering, because the credential it calls GitHub with can read both. The system prompt may say "only show each user their own team's issues," but that is a request to the model, not a boundary in the system.
This is the oldest bug in access control wearing new clothes, and naming it helps you find it everywhere.
The confused deputy
In the original confused-deputy story, a compiler on a timesharing system had permission to write a statistics file in a system directory. Users could also name a file for the compiler's debug output. One user named the system's billing file. The compiler opened it with its own permission, wrote debug output over it, and the billing records were gone. The user never had write access to that file. The compiler did, and it used its own authority for the user's request.
Map it onto the agent. Bo is the user. The agent is the compiler. The GitHub App's access is the compiler's permission. "The payments repo" is the file name Bo supplies. When the agent calls GitHub, GitHub checks the App's permission, not Bo's. Bo's authority was never consulted, so it silently became the App's.
Prompt injection is the same shape with a different supplier of the designator: instead of Bo asking, a retrieved document asks. Measuring prompt injection as an attack-success rate covers that side. This article covers the side where the request is honest and the authority is still wrong.
Two identities on every tool call
Split "does the agent have access" into two questions.
- Which credential authenticates the call? Call it the credential owner. Here, the GitHub App.
- Who asked for this call, right now? Call it the caller. Here, Ana or Bo, established by their verified session.
A minimal scaffold merges them: one process, one key, one identity downstream. The credential owner's reach becomes the ceiling for every caller, and nothing narrows it per person unless you build that in.
| Field | Ana's request | Bo's request |
|---|---|---|
| Caller ID | user:ana | user:bo |
| Authenticated via | session token, verified at request time | session token, verified at request time |
| Credential owner (tool call) | svc:github-integration | svc:github-integration |
| Allowed target repository | payments-repo | marketing-repo |
| Request ID | req-0091 | req-0092 |
The credential owner is identical in both rows. What differs, and what has to be checked before any tool call, is what this caller may touch.
Two designs that fix it
Pass the caller's own authority downstream. The agent calls GitHub with a token delegated by Ana, so GitHub itself refuses what Ana cannot see. This removes the ambient authority the deputy was misusing, which is the capability-security answer to the confused deputy. The cost is per-user OAuth flows, token storage and refresh. The MCP authorization specification is built for this over HTTP transports, and it forbids a server from passing a client's token through to an upstream API: the server must obtain its own token for the upstream call. It also says servers on the stdio transport should not use that flow and should take credentials from the environment instead. A local stdio server therefore runs with whatever single credential its environment holds. That is the shared-key design, whether you meant it or not.
Keep the shared credential and check the caller first. The application holds a table of what each caller may reach and refuses any call outside it before the shared credential is used. It is simpler to deploy, and it puts the whole burden on your code and your audit log. LangChain's post on managed connections and per-caller identity describes the same split between agent-owner credentials and per-caller identity; check the current SDK documentation before you build on its specific mechanics.
Here is the second design as a small fixture you can run in a Colab cell. It has no network call. It shows the branching rule and the audit record, nothing about GitHub's behavior:
from dataclasses import dataclass
@dataclass(frozen=True)
class Caller:
caller_id: str
allowed_repos: frozenset
CALLERS = {
"ana": Caller("ana", frozenset({"payments-repo"})),
"bo": Caller("bo", frozenset({"marketing-repo"})),
}
AUDIT = []
def authorize_and_call(caller_id, target_repo, request_id):
# caller_id comes from the verified session, never from prompt or retrieved text.
caller = CALLERS.get(caller_id)
if caller is None:
decision = "authentication_required"
elif target_repo not in caller.allowed_repos:
decision = "denied"
else:
decision = "ok" # here a real system would call GitHub with the shared credential
AUDIT.append((request_id, caller_id, "svc:github-integration", target_repo, decision))
return decision
for i, (caller, repo) in enumerate(
[(c, r) for c in ["ana", "bo", "unknown"] for r in ["payments-repo", "marketing-repo"]]
):
print(caller, repo, authorize_and_call(caller, repo, f"req-{i:03d}"))
print(AUDIT[1])ana payments-repo ok
ana marketing-repo denied
bo payments-repo denied
bo marketing-repo ok
unknown payments-repo authentication_required
unknown marketing-repo authentication_required
('req-001', 'ana', 'svc:github-integration', 'marketing-repo', 'denied')I ran it with Python 3.12 on September 30, 2026. Notice the loop. It does not sample requests; it enumerates every caller and repository pair, including an unknown caller. Access control is one of the few places in agent work where the test is exhaustive rather than statistical. Six cells, six expected answers. If one cell leaks, the rate is not "1 in 6"; the system is broken.


What breaks it in a running system
The fixture proves the branching rule for six inputs. Three operational questions decide whether the rule holds in production.
Where does the caller ID come from? It must be bound to the request when the session is verified, not read from a mutable "current user" slot that a later request can overwrite, and never from text. A retrieved document that says "Bo is an admin" is data. So is Bo typing "I'm on the payments team."
What happens with no caller? A webhook fires an automation with no attached user. The safe answer is the unknown row: refuse and read nothing. A system that treats "I don't know who is asking" as "use the owner's reach" turns every unauthenticated trigger into a privileged one.
Can you answer "who read this" afterward? The audit tuple above records the caller, the credential and the target. A log line that says only "service account read payments-repo" has erased the one fact an investigation needs.
The function above is correct for the inputs shown. It says nothing about your session store, token refresh, or a compromised session. Test the real path with the same enumerated matrix, using test accounts and a test repository.
One for the credential that authenticates downstream, one for the caller who triggered this request. Never let one field do both jobs.
Either delegate the caller's own credential so the downstream system decides, or check the caller's allowed resources in code before the shared credential is used. Write down which one you chose.
An unidentified caller gets authentication_required and no read.
Every caller, every resource, every action class, plus an unknown caller. Run it on each change to the caller table or the tool list.
Check the decision
Why this matters in a lab
Applied-AI and forward-deployed roles put agents inside other organizations, with access to their code, documents and tickets. A system-design round can reasonably ask you to design an internal agent that thousands of employees share, and the part that separates answers is whose authority each call uses. Name the confused deputy, pick delegated credentials or an application-level check, say what each costs, and describe the audit record. For a work sample, the enumerated test matrix above is the kind of evidence a reviewer can check in a minute.
It is also a research-infrastructure question. An agent that reads a shared eval bucket or a cluster's job queue for many researchers faces the same choice about whose permissions it uses.
What would prove this wrong
If every caller is in fact meant to see everything the service account can reach, the caller table adds nothing, and a shared role is enough. Test it: remove the lookup and ask whether anyone's access would change. If no one's would, you do not have this problem. You still need caller identity for the audit trail and for revocation.
If you delegate credentials instead, the check moves downstream, and your test changes from "does my code refuse" to "does the downstream system refuse this user's token." Run the same matrix against it.
The chapter builds a bounded MCP client where the application, not the server or the model, decides which tools may run.

