Give your agent GitHub access, not your keys.
In May 2025, researchers at Invariant Labs published an attack that should have ended a whole category of demos. They planted a malicious issue in a public GitHub repository. A developer asked their AI assistant, connected to GitHub's MCP server, to check the open issues. The agent read the issue, followed the instructions hidden inside it, pulled data out of the developer's private repositories, and published it in a public pull request.
No exploit code. No stolen credential. Every individual API call was authorized. The researchers called it an architectural flaw with no obvious fix, and they were right: in July 2026, over a year later, security researchers demonstrated the same shape of attack against GitHub's agentic workflows. A related study tricked Claude Code, Gemini CLI, and GitHub Copilot into leaking their own API keys through issue and pull-request text.
So the question of this guide is not the one most tutorials answer. It is not "how do I wire an agent to GitHub" — the official MCP server and every major AI client's GitHub connector made that a ten-minute job. The question is how you connect an agent to your codebase so that the attack above, which is still working, doesn't work on you.
The failure has a name
Simon Willison calls it the lethal trifecta: an agent that combines access to private data, exposure to untrusted content, and the ability to communicate externally can be manipulated into exfiltrating data, with no software vulnerability anywhere in the stack. A public issue is untrusted content. Your private repos are private data. A pull request is an outbound channel. The GitHub attack is simply the trifecta assembled on one platform.
Two things follow, and they shape everything below. First, you cannot prompt your way out: injected instructions have no reliable signature, so "ignore malicious instructions" in a system prompt is a wish, not a control. Second, the fix is structural, and it is almost boring: make sure no single run holds all three legs at once. A run that reads public issues shouldn't hold private-repo access. A run that holds private-repo access shouldn't have an open outbound channel. Remove one leg and the attack collapses.
What that means for how you connect
Handing the model a credential and a broad API — whether that's a pasted personal access token or an unrestricted connector session — assembles the trifecta by default. The safer shape puts a thin server between the agent and GitHub, and it enforces four things.
1. Verbs per task, not a toolbox per agent
Expose the two or three named tools this task needs — open_pull_request, comment_on_issue — and nothing else. A triage task gets issue-reading tools and no repo-content access. A code-review task gets repo read and PR write, and never reads public issues in the same session. The tool list is how you take a leg off the trifecta per run, instead of hoping the model declines the bait.
2. The credential stays on the server
The agent says "open a PR titled X from branch Y"; the server attaches the token and makes the call. The token is never in the prompt, the client, or a log line. If a transcript leaks, it leaks a request, not a key.
3. A policy that can say no, at call time
Between the agent and the action sits a rule the model cannot talk itself past: allow, refuse, or require a human, per verb. Merges to main, deletes, force-pushes live behind a person. A refusal leaves a record:
{
"run_id": "run_7b3e",
"tool": "merge_pull_request",
"target": "acme/web#412 -> main",
"decision": "refused",
"reason": "policy: merge_to_default_branch requires human approval",
"human_needed": true,
"retry_safe": true
}
4. Every call lands on a receipt
The Invariant attack was found by researchers, not by the victim. That's the tell: without a per-run ledger of every tool call and decision, an exfiltration looks like a normal afternoon. A receipt turns "did the agent leak anything at 2am?" into a query instead of a forensics project.
GitHub agrees, in its own documentation
This is not a fringe position. GitHub's official MCP server ships read-only mode, lockdown mode (which suppresses content from users without push access — a direct response to the poisoned-issue attack), composable toolsets, and per-tool exclusion. GitHub's own agentic workflows run the MCP server permanently read-only, with writes routed through a separate permission-controlled job. Fine-grained personal access tokens and GitHub Apps narrow the credential underneath. Use all of it; the vendor's own mitigation strategy is the same word this article keeps repeating: narrow.
There's a quieter reason to narrow, too. The full GitHub MCP server exposes on the order of ninety tools, and one published analysis measured roughly 55,000 context tokens consumed at initialization just to load their definitions; a typical multi-server enterprise stack burns six figures of context before the user types a word. Past the security argument, agents with huge tool catalogs pick wrong tools more often and cost more per run. Three verbs beat ninety on every axis that matters in production.
The part no single vendor can fix
Here is the uncomfortable extension. GitHub can harden GitHub. But your agent's run doesn't live inside one product: the same session that reads a public issue may hold a Slack tool, a database tool, a ticket tool. The trifecta doesn't care which system contributes each leg. Untrusted content can enter through GitHub and exfiltrate through Slack; GitHub's lockdown mode will never see it, and neither will Slack's admin console, because each vendor governs its own silo and the attack lives in the seams between them.
That's why the policy and the receipt have to sit at the level of the run, above every connector: one place that can see "this run read untrusted content, so outbound writes are frozen," and one ledger that records what actually happened across all of it. It's the same argument we make for guardrails that don't live in the prompt and for keeping an agent's keys out of its own hands.
Before you connect GitHub to an agent
- Map the trifecta per task. For each job the agent does, write down which legs it holds: private data? untrusted content? outbound channel? If all three, split the task or drop a leg.
- Scope the verbs. Expose only the tools that task needs. Use GitHub's toolsets, read-only and lockdown modes, and a fine-grained token underneath.
- Put irreversible actions behind a person. Merge, delete, force-push: allow, refuse, or require-a-human, decided on the server, backed by branch protection.
- Read a refused call's receipt. Trigger a blocked action on purpose. If you can't reconstruct what the agent tried from the log alone, fix that before it runs unattended.
Where OBTO fits
OBTO is the layer above the connectors. Wrap GitHub's official MCP server or wrap a token directly; either way, the agent's calls pass through a policy you control, the credentials stay server-side, and every call — GitHub, Slack, database, all of it — lands on one Glass Receipt for the run. Cross-system rules like "runs that read public issues can't write anywhere" become configuration instead of hope. It's the same pattern we use to put an agent on ServiceNow, where the untrusted content arrives as helpdesk tickets, and to front Postgres. Describe it, ship it, own it — including the 2am runs.
The attack that opened this article worked because every step was individually authorized and nobody was positioned to see the whole picture. That's the standard to hold your setup to: could you have seen it? If the answer lives across four dashboards and a prayer, the connection isn't done yet.