The moment you give an agent write access to a repository, a cloud account, or a ticketing system, its capabilities stop being a demo and start being a liability. A model that can open a pull request can also, in principle, force-push to main, paste a secret into a commit message, or deploy to production at 2am because a tool description said it could. The interesting engineering problem was never the reasoning. It's the blast radius.
This is how I think about that: organize guardrails around the user driving the agent, enforce them outside the model, and layer defense so that no single failure is catastrophic.
01 — the core ideaThe persona is the user
The cleanest starting move is to stop treating a "persona" as a bespoke agent configuration and start treating it as the human being who drives it. The agent authenticates as that user, inherits their existing organizational role and permissions as a hard ceiling, and can never exceed what the person is themselves allowed to do.
Don't invent a permission system for agents. Reuse the one that already governs the humans.
That single decision buys three properties for free. There's no parallel permission system — agent scope derives from the RBAC that already governs the human, so a PM's agent cannot write code because the PM's own credentials can't. Accountability is clear — every action traces to the person it acted as. And deny-by-default falls out automatically: anything the user can't do, the agent can't either.
Each persona then collapses to two questions: what can this user do in the org (which sets inherited scope), and how well can they verify the agent's output (which sets required rigor).
Reuse the org's existing RBAC — don't invent a second permission system for agents.
02 — the mapFour roles, two agents
For a team shipping software, the users worth modeling are the individual contributor, the tech lead, the product manager, and the engineering manager — each driving a coding agent (writes code, opens PRs) or a planning agent (reads context, produces plans and tickets), or both. Laid against those two agents, the scope-and-rigor picture looks like this:
| Coding agent | Planning agent | |
|---|---|---|
| IC engineer | Primary · low Write to own branches, open PRs. No merge or deploy. Reviews every diff. |
Secondary · low Decompose own ticket into steps. Read code + tickets. Informal. |
| Tech lead | Occasional · low Same write scope as IC. Leverage is in planning, not code. |
Primary · medium Read the whole codebase, sequence work, weigh trade-offs. |
| Product manager | Not granted No code write access. Read-only status at most. |
Primary · high Produce scoped tickets. Can't verify feasibility — tech-lead gate. |
| Eng manager | Oversight · medium Read code, PRs, metrics. Approve. Delegates writes. |
Oversight · medium Read plans, status, risk. No production writes. |
Two things this surfaces. The PM + coding agent cell is deliberately empty — the org role has no code-write authority, so neither does its agent, no special-casing required. And the rigor gradient runs opposite to technical ability: the IC's coding agent is the loosest because they catch errors, while the PM's planning agent is the tightest because they can't. Rigor tracks who can verify, not who is senior.
Rigor tracks who can verify the output, not who's senior.
03 — the methodBuilding the guardrails
The same four-step procedure runs for every persona; only the inputs change.
- Bind the agent to the user's identity. It authenticates as the user and inherits their RBAC as a hard ceiling — the foundational guardrail, nearly free, with deny-by-default baked in.
- Sub-scope to what the agent needs. Inherited permissions are the ceiling, not the grant. Least privilege gives the agent the intersection of what the user may do and what the task actually needs.
- Set rigor from verification capacity. If the driving user can catch mistakes, soft guardrails suffice. If they can't, the agent must compensate with self-checks and a hard gate where someone who can verify signs off.
- Place gates and escalation. Where an action is irreversible or unverifiable, insert a human approval gate. Make the boundary a structured hand-off, not a dead-end refusal.
Inherited identity sets the ceiling; least privilege sets the grant.
04 — enforcementWhere the limit actually bites
Here's the part that has to be right: a limit that lives in the prompt is not a limit. "Only use this for staging" written into a tool description is persuasion — the model can ignore it. Real enforcement lives in the path between the agent and your systems. A request runs as the user down a pipeline, and any stage can stop it. Pick a role and an action below and watch where it lands.
The two purple stages do double duty. The broker checks each action against the user's role; the scoped credential is a second, independent ceiling — a token pre-narrowed to what the user may touch. So even if the policy check were bypassed, the credential still can't exceed the role. The agent can want to exceed its role; it just structurally can't.
A limit that lives in the prompt is not a limit — enforce it in the broker.
05 — the mechanismDoing it with MCP
Concretely, this is where MCP earns its keep. For most agents the connected MCP servers are the only door to the outside world — the agent can't hit an endpoint that isn't exposed as a tool. So "limit access to endpoints" becomes "control what the MCP layer exposes and enforces." The strongest shape is a proxy MCP: the agent connects only to it, holds no real credentials, and every call descends a stack of checks before reaching a real server.
Two cautions that are specific to MCP, because they're where teams get burned. Tool descriptions are not a security boundary — every real limit has to run in the server or proxy code, never in the natural language the model reads. And tool results are an injection surface — a fetched page or a ticket body can carry instructions trying to get the agent to call something dangerous, the classic confused-deputy path. Combine untrusted results with tight allowlists and human gates so a poisoned result can't reach a destructive endpoint.
Tool descriptions aren't security. Tool results are an injection surface.
06 — defense in depthBeyond the MCP ring
MCP guards the agent's hands — what it can touch. But that's one ring. A call reaching an allowed endpoint isn't the only failure mode. Around MCP sit the layers that guard what the agent reads, emits, and runs, and how far it can go.
The one that people forget is the sandbox. MCP scoping is moot if the agent can curl an arbitrary host from its shell — so the environment ring matters as much as the endpoint ring. For a coding agent, the two highest-leverage layers outside MCP are the sandbox with egress control (contains the damage) and the independent judge (catches what static policy can't express).
MCP is one ring — the sandbox and the judge catch what it can't.
07 — the takeawayWhat it comes down to
Strip it back, and the whole design rests on four ideas:
Hard guardrails enforce the ceiling. Identity, scope, and gates run in code — not persuasion.
Soft guardrails shape behavior underneath. Persona and self-checks make the agent want to stay in bounds.
Rigor tracks verification capacity. How much you need is set by whether the human can catch the mistakes.
Boundaries are hand-offs, not walls. At its edge, a good agent escalates rather than refuses.
The agent can reason its way toward anything. The job is to make sure that when it does, the only things it can actually reach are the ones the person behind it was always allowed to touch.
08 — in practiceA reference implementation
None of this is abstract. The same design drops into GitHub Copilot CLI as a guardrail proxy: Copilot launches it as its GitHub MCP server, and every call is filtered, checked, credentialed, and logged before it reaches the real github-mcp-server. Copilot never holds the token. The whole thing is open source — Node built-ins only, and it runs offline against a mock upstream, so you can clone it and watch the policy tests pass without a Docker daemon or a GitHub token.
# clone and run the tests — no Docker or token needed
git clone https://github.com/thundeeran/labs
cd labs/guardrailing-agents && npm test
// ~/.copilot/mcp-config.json — Copilot connects only to the proxy { "mcpServers": { "github-mcp-server": { // this name replaces Copilot's built-in server "command": "node", "args": ["/path/to/proxy.mjs"], "env": { "GUARDRAIL_PERSONA": "ic-engineer", "GUARDRAIL_TOKEN_SOURCE": "command", "GUARDRAIL_TOKEN_CMD": "/path/to/mint-github-token.sh" // mints a short-lived token, JIT } } } }
proxy.mjs — a newline-delimited JSON-RPC stdio broker, no dependencies
No token lives in the config. A long-lived secret in a config file doesn't scale and is a leak waiting to happen. Instead the proxy mints a short-lived, scoped token just-in-time — a GitHub App installation token (~1h TTL, scoped to specific repos and permissions) — injects it into the upstream only, and rotates it before it expires. Nothing durable to steal, nothing to rotate by hand.
The proxy enforces the persona policy verbatim. For an IC engineer it filters merge_pull_request out of the tool list, denies writes to main, and blocks any repo outside the allowed set. For a PM it exposes read-only tools and nothing else. For an eng manager it holds merge_pull_request for out-of-band human approval. Every decision lands in an audit line.
One caveat the code makes obvious: the proxy only guards MCP. Copilot can still run shell commands, so you pair it with narrow --deny-tool shell permissions and a sandbox with egress control — otherwise the agent could curl GitHub directly and walk around the whole thing.
Credentials, just in time
The minter is any command that returns a short-lived, scoped token for the persona. The proxy runs it at session start and again before expiry:
# GUARDRAIL_TOKEN_CMD <persona> → prints JSON on stdout $ mint-github-token.sh ic-engineer { "token": "ghs_...", "expires_at": "2026-08-01T12:00:00Z" }
A GitHub App installation token is the natural source — scoped to specific repos and permissions, ~1h TTL. When it nears expiry the proxy re-mints, respawns the upstream, and replays the MCP handshake, so a long session never runs on a stale or over-broad token — and there is no durable secret to leak.
One proxy, many personas
Personas are session-scoped — one per Copilot session. Each gets its own isolated config and its own scoped token; you switch between them rather than running them side by side. Register two GitHub servers at once and the agent just reaches for the more permissive one.
# no long-lived token anywhere — each persona mints a short-lived one JIT bash copilot-as.sh ic-engineer # isolated COPILOT_HOME, guarded session bash copilot-as.sh pm # a separate, read-only session
Every profile is the same proxy with just one value swapped — the persona — and no token in the file:
// ~/.copilot-profiles/<persona>/mcp-config.json { "mcpServers": { "github-mcp-server": { "command": "node", "args": ["/path/to/proxy.mjs"], "env": { "GUARDRAIL_PERSONA": "ic-engineer", // ← the only thing that differs "GUARDRAIL_TOKEN_SOURCE": "command", "GUARDRAIL_TOKEN_CMD": "/path/mint-github-token.sh" // ← JIT, scoped per persona } } } }
copilot-as.sh writes this per-persona under ~/.copilot-profiles/ and launches with COPILOT_HOME + --disable-builtin-mcps