Least privilege for AI agent tools, explained through two MCP data leaks
In the GitHub and Supabase MCP leaks of 2025, prompt injection decided what the agent wanted, but its credentials decided what it could reach. A practical guide to the permission model behind agent tools: the confused deputy, OWASP’s excessive agency, whose identity a tool call carries, and five controls that bound an injected agent, from task-scoped tokens to database roles.
By The Xalgorix Team
“No permissions were violated. The agent just followed instructions it should never have trusted.” That is how General Analysis summed up its July 2025 demonstration against the Supabase MCP server, in which a support ticket talked a developer's coding assistant into dumping a secrets table back into the ticket thread. Six weeks earlier, Invariant Labs had shown the same outcome with GitHub's official MCP server: a public issue led an agent to copy private-repository data into a public pull request. Both were researcher demonstrations on test accounts and dummy data, not attacks observed in the wild, and both worked without exploiting a single code bug.
Both reports are usually filed under prompt injection, and they are. But prompt injection only decided what the agent wanted to do. What it was able to do came from somewhere else: the credentials and tools it had been handed. This article is about that second half — the permission model behind agent tools — because it is the half an engineering team can change today, without waiting for models to stop being persuadable.
Two leaks where every access check passed
GitHub, May 2025. Invariant Labs' write-up (26 May 2025) describes a user with one public and one private repository, the GitHub MCP server connected to Claude Desktop, and Claude 4 Opus as the model. An attacker opens an issue on the public repository containing instructions for the agent. The user asks something harmless, such as to look at the open issues. The agent reads the planted issue, pulls data from the user's private repositories into context, and opens a pull request on the public repository containing it. The researchers stress this is “not a flaw in the GitHub MCP server code itself” but an architectural issue at the agent-system level: the token could read every repository the user could, and nothing tied the session to the one repository the task was about.
Supabase, July 2025. General Analysis built a mock multi-tenant support app with Row-Level Security enabled. A developer used Cursor with the Supabase MCP server to review tickets, and the assistant ran SQL as service_role, which bypasses RLS. An attacker filed a ticket that addressed the assistant directly and asked it to read the integration_tokens table and post the contents into the ticket. When the developer asked to see the latest ticket, the assistant ran two queries: one read the tokens, one inserted them as a new message on the attacker's ticket. The support staff's own role could not read that table. The agent's credential could.
Simon Willison's note on the Supabase case points out that one MCP server supplied all three legs of his “lethal trifecta”: private data, untrusted instructions and a way to send data back. We covered that framing, and how to test for it, in our prompt injection piece. Here the focus is narrower: the identity each tool call runs as, and the controls that shrink it.
An old bug class with a new deputy
Norm Hardy described the confused deputy in 1988: a program holding authority on behalf of one party is tricked by another party into using it. An LLM agent is close to an ideal deputy. It holds credentials, it accepts instructions in natural language, and it cannot reliably tell the user's instructions from text that merely arrived through a tool.
OWASP's LLM06:2025 Excessive Agency gives the condition a name and splits it into three root causes, each visible in the two leaks:
- Excessive functionality — tools “not needed for the intended operation of the system.” Reviewing tickets does not need an
INSERT; triaging issues does not need pull-request creation. - Excessive permissions — rights on downstream systems the task does not need. A
service_rolekey and an all-repository token are both this. - Excessive autonomy — high-impact actions without independent approval. Invariant notes many users pick “Always Allow” and stop watching tool calls.
One terminology trap: the MCP security best practices also have a “Confused Deputy Problem” section, but it covers a specific OAuth attack on MCP proxy servers (static client IDs, dynamic registration and consent cookies letting an attacker obtain an authorization code). It is worth fixing if you run such a proxy, and it is a different bug from the agent-level deputy problem in this article.
Whose identity does each tool call carry?
The most useful question to ask of any agent integration is not “can the model be injected?” (assume yes) but “when a tool runs, whose authority does it use?” In practice there are three answers.
The Supabase demo sat in the first box and the GitHub demo in the second. Moving right is mostly configuration work, and every vendor control mentioned below is a way of moving right.
Five controls that bound an injected agent
1. Issue a credential for the task, and only to the tool server
For GitHub, a fine-grained personal access token can be limited to “only select repositories” with per-permission grants, and GitHub recommends fine-grained tokens over classic ones. For Supabase, the hosted MCP server can be pinned to a single project (General Analysis points to its project_ref option; Supabase's own defense-in-depth post of 16 September 2025 calls it project-scoped mode). That postcalls it project-scoped mode), whose blunt core advice is to never connect AI agents directly toapos;s blunt core advice is to never connect AI agents directly to production data. If you build your own MCP server, the spec's rule on token passthrough applies: servers “MUST NOT accept any tokens that were not explicitly issued for the MCP server.” Forwarding the client's token to the upstream API erases the audience boundary and the audit trail at the same time.
2. Keep write tools off unless the task writes
Both exfiltration paths ended in a write: a pull request and a ticket message. GitHub's MCP server has a read-only mode (--read-only locally, the X-MCP-Readonly header remotely) that “will disable all tools that are not read-only even if they were requested,” plus toolset and per-tool allowlists and an exclude list that always wins. Supabase offers read-only mode and feature groups. Be precise about what this buys: it removes the write channel the demos used, but the agent can still read everything the credential reaches, and a later tool (a web fetch, a chat message) can become the next outbound channel. General Analysis says the same: read-only blocks that exfiltration step, but reads the role can perform remain possible.
3. Pin the session to the resource the task named
Invariant's suggested policy is simple: raise a violation when the repository or owner argument changes between repository tool calls in the same session. You can enforce the same idea in any tool gateway that sits between the model and the MCP server, without relying on the model to behave:
// Minimal tool gateway: pin each agent session to the first repo it touches,
// and deny write tools unless the session was opened for a write task.
type Call = { tool: string; args: { owner?: string; repo?: string } };
type Session = { pinned?: string; allowWrites: boolean };
const WRITE_TOOLS = new Set(["create_pull_request", "create_or_update_file", "add_issue_comment"]);
export function authorize(session: Session, call: Call): void {
if (WRITE_TOOLS.has(call.tool) && !session.allowWrites) {
throw new Error("denied: write tool in a read-only session: " + call.tool);
}
if (call.args.owner && call.args.repo) {
const target = (call.args.owner + "/" + call.args.repo).toLowerCase();
session.pinned ??= target; // first repo becomes the scope
if (session.pinned !== target) {
throw new Error("denied: session pinned to " + session.pinned + ", got " + target);
}
}
}Better still, take the pin from the user's request or UI rather than the first call, so an injection that lands before the first repository call cannot choose the scope. And log every denial: a cross-resource attempt in a session that started from untrusted content is a high-signal detection.
4. Let the downstream system say no
OWASP calls this complete mediation: enforce authorization in downstream systems “instead of relying on the LLM's judgment.” In the Supabase scenario the application already had the right policy — RLS, and a support role that could not read secrets. The agent simply connected around it. Giving the agent its own database role, granted only what the feature needs, puts the database back in charge:
-- A role for the support assistant: read tickets, nothing else. CREATE ROLE support_assistant NOLOGIN; GRANT USAGE ON SCHEMA public TO support_assistant; GRANT SELECT ON public.tickets, public.ticket_messages TO support_assistant; -- No grant on integration_tokens, no INSERT anywhere. -- The role is not a superuser and lacks BYPASSRLS, so your existing -- row-level policies still decide which tickets it can see. (With RLS -- enabled and no policy for this role, it sees no rows at all.)
An injected SELECT * FROM integration_tokens now fails with a permission error from Postgres, whatever the model was persuaded to try. The same logic applies to every API an agent calls: if the backend would refuse the request from the human user, it should refuse it from the agent acting for that user.
5. Make approvals about arguments, and treat filters as filters
Per-call approval only helps if the prompt shows what matters: the tool, the full arguments and where data will go. “Allow execute_sql?” invites a reflex click; “Run SELECT * FROM integration_tokens and post the result to ticket #4812?” does not. Supabase's post advises keeping per-call approval on and being wary of “Always Approve.”
Content filters belong in the stack too, as long as nobody mistakes them for access control. GitHub's MCP server added a lockdown mode that, in public repositories, only surfaces content from users with push access; its own documentation calls it “a best-effort content filter meant to reduce prompt-injection risk” and “not an authorization boundary.” Supabase likewise reports that wrapping query results in warnings “reduced risk but did not eliminate it.” Filters lower the hit rate; controls 1–4 cap the damage when one gets through.
Auditing an agent's permissions in a pentest
When an assessment includes an agent feature or an internal MCP deployment, the permission review is quick and often more productive than payload fuzzing:
- Inventory the tools each agent can call, and for each one, the credential behind it: a user token, a shared key, or something minted per task.
- Diff reach against need. List what the credential can reach, then what the feature requires. Every row in the first list but not the second is a finding, before any injection is attempted.
- Find the untrusted inputs that reach the model through read tools: tickets, issues, emails, documents, web pages, database rows written by customers.
- Plant a canary, not a payload. With the owner's agreement, seed a synthetic record the agent should never touch and an instruction in an untrusted channel that asks for it. If the canary leaves the boundary, the impact is demonstrated without real data at risk.
- Check where the controls live. A refusal from the model is not a control; a permission error from the database or API is.
These rules apply to autonomous security tools as well, Xalgorix included. An agent that reads hostile web responses all day is the textbook case for a narrow, per-engagement credential, an explicit target scope and an egress allowlist, enforced outside the model. No amount of model alignment substitutes for that: Invariant's demo hijacked Claude 4 Opus, a frontier model released days earlier.
Ready to see it prove a bug?
Start scanning on Cloud →