Give your agent GitHub access, not your keys.
A GitHub personal access token with repo scope can force-push to main, delete a branch, rewrite history, and close every open pull request in the repository. That is the exact credential most tutorials tell you to paste into a config file when you want to connect GitHub to an AI agent. It works on the first try, which is precisely the problem.
The token works. The demo is green. And you have quietly handed a non-deterministic process the same keys your senior engineer carries, with none of the judgment your senior engineer brings. The good news: the safer way is not much more work, and it is the difference between an agent that can open a pull request and one that can do anything the token allows.
There are two ways to wire an agent to GitHub
The first is to hand the model a credential — a personal access token, a fine-grained token, or a GitHub App installation token — and let it call the GitHub API directly. It is fast to set up. It also means every permission that credential carries is now reachable by whatever the model decides to do next.
The second is to put a thin server between the agent and GitHub. That server exposes a few named verbs — open_pull_request, comment_on_issue, request_review — holds the token itself, and decides, on every call, whether to allow the action, refuse it, or ask a human. The model never sees the credential. It sees a menu.
The distinction isn't cosmetic. In the first design, the boundary is the model's good behavior on a given afternoon. In the second, the boundary is code you wrote and can reason about. Production is a game about the worst case, and only one of these two sets the worst case deliberately.
What the safe pattern actually looks like
1. Give it verbs, not the API
Don't expose "call the GitHub REST API." Expose the three or four things the agent genuinely needs: open a PR from a branch, leave a review comment, add a label, read an issue. Each becomes a named tool with a typed input. An agent that only holds open_pull_request cannot force-push to main — not because you asked it nicely, but because there is no verb for it to reach.
2. Keep the token on the server
The credential lives inside the server that implements those verbs. It is never in the prompt, never in the browser client, never echoed into a log line. The agent says "open a PR titled X from branch Y"; the server attaches the token and makes the call. If a transcript leaks, it leaks a request, not a key. That single rule — the model asks, the server holds — is the whole of keeping an agent's secrets out of its own hands.
3. Put a policy in front of the write
Reading an issue is safe. Merging to main is not. That decision cannot live in the prompt, because a prompt is a request the model can talk itself past. It belongs on the server, between the agent and the action: allow the safe verbs, refuse the dangerous ones, and require a human for the ones in between. The model's intent is an input to that decision, never the final word — the argument we make in full in guardrails that don't live in the prompt.
A refusal should leave something behind. Here is what one looks like when an agent asks to merge a PR that policy reserves for a human:
{
"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
}
Nothing was written. The agent gets a clear no, you get a one-line answer to "what did it try to do?", and the pull request is still sitting there for a person to approve.
4. Write down every call
Every verb the agent invokes should leave a receipt: which tool, what arguments, allowed or refused, and what GitHub returned. When a strange PR shows up at 2am, the receipt is how you learn what happened without reconstructing it from memory. An agent with write access and no ledger is a black box that occasionally edits your codebase.
A verb, up close
Concretely, the tool you expose is small and boring on purpose — a typed input, a fixed set of fields, and no room to improvise:
open_pull_request(
repo: "acme/web", // fixed allow-list, not free text
head: branch, // the agent's working branch
base: "develop", // never "main"
title: string,
body: string
) -> { number, url } // returns a PR, nothing more
The agent can fill in a title and a body. It cannot choose to target main, cannot point at a repo that isn't on the list, and cannot reach any GitHub call you didn't wrap. Everything it's allowed to do is visible in five lines, which is also everything you have to reason about.
Before you connect GitHub to an agent
Four checks, in order. Each one closes a gap the naked token leaves open.
- Scope the verbs. Write down every GitHub action the agent needs. Expose those as named tools and nothing else. The list is your real permission model.
- Keep the token server-side. The credential lives in the server behind the verbs. If it can appear in a prompt, a client, or a log, move it.
- Add a refuse rule. Decide per verb: allow, refuse, or require a human. Put irreversible actions — merge, delete, force-push — behind a person, and back it with branch protection so
maincan't be written directly. - Read a refused call's receipt. Trigger a blocked action on purpose and confirm you can tell what the agent tried, from the log alone. If you can't, neither will you at 2am.
But GitHub already ships an MCP server
It does, and you should use it. GitHub's official remote MCP server handles OAuth, and Claude, Cursor, ChatGPT, and VS Code all ship GitHub connectors that speak to it — point an agent at one and it can read repos, manage issues, and open PRs with no glue code from you. Underneath it still runs on a token, a fine-grained PAT or a GitHub App, so the scoping advice above still holds: grant the narrowest repositories and permissions the job needs, and run it read-only or with a trimmed toolset wherever you can.
A connector gives you what's possible. It hands the model a broad GitHub toolset and trusts it to reach for the safe ones. What it doesn't give you is a decision at call time: it won't refuse this merge to main because your policy reserves that for a human, and it won't hand you a single receipt that spans GitHub and the database and the ticket system the same run touched. Permissions set what's possible; a policy sets what's allowed right now; a receipt records what happened. The connector covers the first. The other two are still yours.
Where OBTO fits
That governance layer is the part OBTO owns. Wrap the official GitHub MCP server, or a token directly — either way the agent's calls pass through a policy you control and land on the Glass Receipt by default, next to every other tool the run touched. It's the same pattern we use to wrap ServiceNow and to put an agent in front of Postgres: narrow verbs, credentials held server-side, a policy that can say no, and one audit trail across all of them. Describe it, ship it, own it — including the repo.
So the question to ask before you wire an agent to your codebase isn't "can the model be trusted with a token?" It's the more useful one: what are the three or four things this agent should be able to do to my repo — and can it reach anything else? Name the verbs. Everything past that list should be a door the agent can't open.