A public issue made an AI agent leak a private repo. We recreated it — and held the public post for a human.
In May 2025, Invariant Labs demonstrated that a malicious issue filed in a public repository could hijack a developer’s AI assistant. The assistant, connected through the GitHub MCP server, read the issue, followed the instructions hidden inside it, pulled contents from the developer’s private repositories, and posted them back where the attacker could see them. The developer did nothing but let their agent triage an issue.
The shape is familiar from the other incidents in this series, but the boundary it crosses is specific: scope. The agent held a token that could read both public and private repos, and a single session moved data from the private side to the public side. The MCP server did exactly what it was asked. Nothing enforced that a session triggered by public, untrusted content shouldn’t be able to read private material and publish it.
This one passes the “ACP prevents it” filter because both damaging steps — the private read and the public post — are governable tool calls. We rebuilt the mechanism in a sandbox and ran a governed twin. The cut we captured live is the public post.
The boundary: one token, two scopes, no wall
The incident is a scope-confusion problem wearing a prompt-injection costume. The token spanned public and private. The triggering content was public and untrusted. The output channel was public. The private read sat in the middle with nothing isolating it from the other two.
Two tool calls carry the damage:
- The cross-scope read. The agent uses the MCP server to read a private repo it has no task-legitimate reason to touch in a public-issue-triage session.
- The public post. The agent writes private-scoped content into a public thread — the exfiltration channel.
The untrusted issue triggers it, but the read and the post are the governable actions. Wall off the private scope, gate the public write, and the leak has nowhere to complete.
What we recreated
An isolated sandbox: a mock public repo carrying a poisoned issue, a mock private repo holding clearly-synthetic sensitive data, fronted by an MCP server, and an agent with a token that spans both.
- A public issue carries an instruction to the agent (defanged — intent conveyed, never a working payload).
- The agent’s token can read the private repo.
- The agent can post publicly — the outbound channel.
Ungoverned, the sandbox agent reproduces the leak: it reads the issue, pulls the private (fake) data, and posts it into the public thread. Every step is a permitted MCP operation in an impermissible sequence. That’s the baseline. Now the same run with the public post routed through a policy.
The governed twin — the public post is held for a human
Same agent, same broad token, same poisoned issue. This time the outbound post passes through ACP before it can publish. In our sandbox the agent’s outbound call is the incident’s exact move — posting a private file’s contents into a public repo’s issue thread:
gh issue comment acme/public-site --body "$(cat private_salaries.csv)"
This is the real rule from our workspace — nothing written for the demo. At the background (autonomous) tier, gh calls are held for approval rather than executed:
{
"mode": "enforce",
"tools": {
"Bash.gh": {
"background": { "permission": "step_up" }
}
}
}
The call leaves the agent, hits the control plane, and — because the agent is running unsupervised at the autonomous tier — it doesn’t publish. It lands in the Approvals queue and waits for a person. Nothing has posted; the private data is still private.
recreated-githubmcp) issued the public post; ACP classified it Bash.gh from an autonomous, unsupervised agent and held it for approval (step_up) by the background-tier policy. As the header says: nothing here has executed. A human sees exactly what was attempted and chooses Approve or Deny. Captured from acp-ops; the repo and data are synthetic.The distinction that matters: the post wasn’t the agent asking nicely and being trusted to wait. It was intercepted below the agent, in the call path. The agent cannot proceed, cannot narrate its way past the hold, and cannot quietly publish a modified version — approving grants a single retry of the exact action, and any change to the parameters requires a new approval. The leak is frozen at the moment it would have gone public, while it’s still contained, and a person decides.
The other boundary — scope the read out of reach
Holding the public post is the cut we can show firing today. The strongest posture also removes the first link, so the private data never loads:
- Scope the token. The triage agent does not need a token that spans private repos. A fine-grained GitHub token limited to the public repo it’s actually working means the cross-scope read simply fails. This is the upstream structural fix, and it’s GitHub-side, not ours.
- Gate the read the same way you gate the post. ACP governs whatever tool performs the read — the MCP server call,
gh, a raw API call — with the same per-tier policy shown above. Set that tool tostep_upordenyfor autonomous agents and the private read joins the public post behind a boundary.
Two independent links, either one sufficient. Scope labelling is never complete, so governing the outbound channel too means the defense doesn’t rest entirely on having tagged every private repo perfectly.
What ACP prevents here — and what it doesn’t
- ACP does not detect the malicious issue. Injection lives in unbounded text; ACP governs the agent’s actions rather than trying to sanitize its inputs.
- ACP does not narrow the GitHub token for you. A token scoped to only what the task needs is the right structural fix. ACP is what makes scope hold at call time, per session, including the public-vs-private wall a raw token doesn’t enforce.
- ACP holds the outbound action for a human. The public post is a tool call, it traverses the call path, and at the autonomous tier it doesn’t publish unattended — it waits for a person who can see precisely what was attempted. Deny it, and the private data can’t leave, regardless of how convincing the issue was.
The assistant in the original demo was not compromised. It read a public issue and used a legitimate MCP server with a legitimate token. The leak happened in the gap between “can read private repos” and “is posting to a public thread” — a gap that only a boundary at the action closes.
Try it against your own setup
If your coding agent connects to source control with a token spanning public and private, and it triages public issues or PRs, the cross-scope path is open unless something walls it off. I think of that wall — the per-session boundary between untrusted input, private scope, and public output — as the agentic control plane’s job; no single token should be trusted to hold all three open at once. Point an agent at a mock private repo and a public post channel and watch whether either call is stopped; getting started has the install, and the tier policy above is the whole config for the outbound cut.
Source: UpGuard — Six MCP Security Incidents (Invariant Labs research).
- 1. An AI agent deleted a production database in 9 seconds. We rebuilt it in a sandbox — and stopped it.
- 2. Replit's AI agent deleted a production database during a code freeze. We recreated the freeze — and held it.
- 3. A pull request told Amazon Q to wipe the machine. We recreated the injected PR — and the destructive calls never ran.
- 4. A support ticket told an AI agent to leak the tokens table. We recreated the lethal trifecta — and held the exfil for a human.
- 5. A public issue made an AI agent leak a private repo. We recreated it — and held the public post for a human. · you are here
- The Gemini cache dead zone: why the newest Flash quietly stopped caching your agent's prompts
- A support ticket told an AI agent to leak the tokens table. We recreated the lethal trifecta — and held the exfil for a human.
- Replit's AI agent deleted a production database during a code freeze. We recreated the freeze — and held it.