You set up an agent to triage issues overnight. It needs GitHub, so you do
the obvious thing: create a PAT, drop it in the environment, go to bed.
Now inventory what you actually deployed. The token is in the environment —
so it's in the environment of every sub-agent the orchestrator spawns, every
tool process they exec, every log line that dumps env on a crash. It's
valid for however long you set at creation (days, realistically — nobody
regenerates a PAT per task). And if one worker goes sideways at 3am, your
revocation story is "delete the token", which also kills every other
agent, pipeline, and script using it.
GitHub's own answers help less than you'd hope. Fine-grained PATs scope
nicely but you mint them by hand in a web UI — there's no "create a
one-hour token for this sub-agent" API call. GitHub Apps do mint
short-lived installation tokens, but now you're operating token-minting
infrastructure, and the token still doesn't subdivide: two sub-agents under
one installation hold the same authority.
Here's the reframe that makes this tractable: the sub-agent never needed
the token. It needed permission to perform three specific GitHub actions,
for an hour. Those are different things, and conflating them is the whole
problem.
So separate them:
- The PAT lives in exactly one process — the tool server that actually calls the GitHub API (your GitHub MCP server, running in the parent's trust domain). It never enters an agent's context window or environment.
- Sub-agents get a capability: a signed, bounded permission to invoke named tools on that server. The tool server checks the capability before every call.
The broker that does step 2 is an MCP server you run locally:
npx -y @grantor/mcp serve
# or wire it into Claude Code:
claude mcp add grantor-mcp -- npx -y @grantor/mcp serve
(Want to watch a denial happen before wiring anything real?
npx -y @grantor/mcp demo — thirty seconds, zero setup, and the refusal
happens in your own terminal.)
The overnight-triage grant looks like this:
grant {tools: ["issue_read", "search_issues", "add_issue_comment"],
max_uses: 50, ttl_secs: 3600}
→ {child_id: "…", sub: "…"}
Those are GitHub's official MCP server's real tool names
(ghcr.io/github/github-mcp-server). Any stdio MCP server wraps the same
way — the broker doesn't care what's behind the --.
The sub-agent gets the child_id (never a key; the broker holds those),
and the tool server gates every invocation:
check {child_id, tool: "add_issue_comment"}
→ {allow: true, remaining_uses: 49}
check {child_id, tool: "merge_pull_request"}
→ {allow: false, code: "CapabilityDenied"} # never granted
An hour later the grant's expiry caveat has passed and every check on
that child is denied (CapabilityDenied, naming the failed caveat) — with
the PAT untouched. If the triage agent spawns its own worker to draft comments,
it can pass down a strictly narrower slice:
delegate {parent: child_id, tools: ["add_issue_comment"], max_uses: 10}
→ {child_id: "…"}
Asking for a tool the parent doesn't hold is refused before anything is
signed; asking for more uses or a longer expiry silently clamps to the
parent's bound.
And if your GitHub tool server speaks MCP, you don't even need it to call
check voluntarily — put it behind the enforcing proxy and the boundary
is structural. Don't know what your server exposes? Ask it:
npx -y @grantor/mcp tools -- docker run -i --rm \
-e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server
# lists its 44 tools, marks the write-shaped ones, and prints a
# ready-to-paste wrap suggestion (a name heuristic — review it)
Reviewing is not optional theater: the suggestion holds add_issue_comment
back as write-shaped — correctly — and the triage bot's whole job is
commenting, so you add that one back on purpose. The heuristic proposes;
you decide. Then wrap:
grantor-mcp wrap --tools issue_read,search_issues,add_issue_comment \
--max-uses 50 --ttl-secs 3600 -- docker run -i --rm \
-e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server
(Note where the PAT lives: -e GITHUB_PERSONAL_ACCESS_TOKEN with no value
forwards it from your shell into the server's container — the one process
from step 1 — and nowhere an agent can read. The official server also has
its own --read-only and toolset flags, and they compose fine; what the
wrap adds is the part flags can't: per-sub-agent budgets, expiry,
delegation that only narrows, and one-call revocation.)
Now the server never sees a request that wasn't authorized, and the
sub-agent's tool list only contains what you granted. When the agent
reaches for the merge button anyway, this is what lands in your terminal:
┌─ DENIED ───────────────────────────────────────────────────────────────────┐
│ requested merge_pull_request │
│ grant allows issue_read, search_issues, add_issue_comment │
│ refused as CapabilityDenied — tool not granted │
│ │
│ Denied by the grant, not by a prompt — the server never saw the call. │
│ revoke everything: grantor-mcp revoke --child 7c9e12aa-triage │
│ what just happened: https://chaingrantor.com/docs/guide/first-denial.html│
└────────────────────────────────────────────────────────────────────────────┘
And this isn't a policy file the broker consults — the
delegation chain is cryptographically signed link by link, and every
check re-verifies the narrowing math, so a child can't widen its slice
even if the broker's pre-check drifted.
The 3am story is one call:
revoke {child_id}
In your own tenant that bumps a revocation epoch on a public smart contract
on Base, and every capability in that cohort — the triage agent and the
worker it delegated to — fails its next check, no matter which process
holds it. The PAT keeps working for everything else you own. That's the
inversion worth noticing: revocation used to be the expensive, blast-radius
operation, and now it's the cheap, surgical one.
Honest limits, same as last time: this is an unaudited developer preview;
the zero-setup run points at a live shared sandbox tenant on the production
registry (real chain, real verification, a publish-on-purpose demo key that
controls nothing outside the sandbox); use-metering is local to your broker
process. And the capability bounds access to your tool server — GitHub
itself still sees one PAT, so the tool server remains the enforcement
point. That's the design, not a gap: one place holds the secret, everything
else holds bounded, expiring, revocable permission to use it.
Production is one contract call away (USDC on Base, no signup — the
register page even takes ETH or any liquid token and swaps it on-chain
inside your own transaction). The 60-second walkthrough of everything
above, ending in a mid-flight revocation:
https://chaingrantor.com/docs/guide/first-denial — broker docs:
https://chaingrantor.com/docs/guide/mcp-broker — or watch the whole
grant → narrow → deny → revoke arc run live against mainnet in your
browser at https://chaingrantor.com/playground.html. The first post in
this series covers the general budget model:
https://dev.to/grantor/give-your-ai-sub-agent-a-budget-not-your-keys-2e7h
Your agents don't need credentials. They need permissions with an expiry
date.
Top comments (0)