← All articles Integrations

Give your agent GitHub access, not your keys.

OBTO Team · Insights from the Glass Box

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

  1. 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.
  2. 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.
  3. Put irreversible actions behind a person. Merge, delete, force-push: allow, refuse, or require-a-human, decided on the server, backed by branch protection.
  4. 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.

Frequently asked questions

Is it safe to connect an AI agent to GitHub?

Only with structural limits. In 2025, researchers showed a single malicious public issue could steer an agent connected to GitHub's MCP server into leaking private repository data, and variants of the attack were still working in 2026. The fix is architectural: narrow the tools per task, keep credentials server-side, put a policy in front of writes, and never combine private-data access, untrusted content, and an outbound channel in one unrestricted session.

What is the lethal trifecta for AI agents?

A term coined by Simon Willison in 2025: an agent that combines access to private data, exposure to untrusted content, and the ability to communicate externally can be manipulated into exfiltrating data without any software vulnerability. Remove any one of the three legs for a given run and the attack collapses. Detection alone can't close it, because injected instructions have no reliable signature.

Should I use GitHub's official MCP server or build my own tools?

Use the official server, restricted. It ships read-only mode, lockdown mode, and composable toolsets precisely so you can shrink what an agent reaches; GitHub's own agentic workflows run it read-only with writes in a separate controlled job. Then add what no single-vendor server provides: a policy that can refuse a specific write at call time, and one receipt across every system the run touches.

What GitHub permissions should an AI agent have?

The fewest that let it do the task at hand. Scope a fine-grained token to specific repositories and exact permissions, run read-only wherever possible, and narrow further at the tool layer so the agent only sees the two or three verbs the job needs. Fewer tools also measurably improves reliability: large tool catalogs consume tens of thousands of context tokens and increase wrong-tool calls.

How do I stop an AI agent from leaking private repository data?

Break the trifecta per run. If a task reads untrusted content like public issues, run it without private-repo access or without outbound channels. Enforce this with a server-side policy between the agent and its tools, not with prompt instructions, and log every call so a leak attempt is visible in the run's receipt rather than discovered weeks later.

See the whole run, not one silo

Scoped verbs, server-side credentials, a policy that can refuse, and one Glass Receipt across every tool the run touches.

Get started

More from the OBTO blog