TL;DR
A signature tells you who wrote an instruction. It doesn’t tell you who allowed it. This part gives every agent a permit: a token that names the human it works for (sub), the chain of agents acting (act), what they may do (scope), and when that ends (120 seconds). Each agent swaps the token it holds for a narrower one, and an OPA policy at the MCP gateway checks every tool call.
Part 2’s signed delete is now denied. So is a permit stolen from the queue. Then a pod that skips the gateway deletes the bucket anyway: a permit only matters on roads that check permits.
Originally published at portfolio.hagzag.com.
The field nobody checked
Part 2 ended with this line, and it has bothered me since:
executor=task task_id=45 step=2 instruction='delete bucket prod-archive' requested_by=haggai signer=spiffe://highway.lab/ns/migration/sa/planner-agent
The signature is real. The requested_by=haggai is signed too, so nobody can change it in the queue. But it was never true. The planner copied it from a command-line flag. I never logged in, never approved a delete, and nothing in the system could tell the difference.
That’s the gap between a license plate and a driving permit. The plate says whose car it is. The permit says who may drive it, on which road, until when, and it’s issued by someone other than the driver.

Break: the key everyone carries
Before permits, the most obvious borrowed credential goes. Since Part 1, every agent pod has mounted the same static LLM API key:
task part3:key
Any pod that can read its own environment walks away with a credential that doesn’t know who’s using it, for what, or until when. The fix is the pattern the rest of this post generalizes. The key moves into one llm-gateway pod. Agents present a JWT-SVID instead: a token SPIRE mints for exactly one audience (aud=llm-gateway), from the same attestation that gave them their plates in Part 2.
llm=ALLOW caller=planner-agent model=gpt-4o-mini mode=mock
llm=ALLOW caller=executor-agent model=gpt-4o-mini mode=mock
llm=DENY reason='no JWT-SVID'
The last line is the rogue pod from Part 2. Same network, no SVID, no model. There’s one place to rotate the key, rate-limit, and cut someone off.
Permits are exchanged, never handed down
The human logs in to Keycloak (lab users hagzag, tester and ops-admin). The planner doesn’t forward that token. It takes it to a Security Token Service and asks, in RFC 8693 terms:
subject_token: who the work is for (the human’s access token)actor_token: who is doing it (the planner’s JWT-SVID,aud=sts)scope: what it wants
The executor repeats the exchange with the planner’s permit as its subject. Every hop gets a new token, and each can only be narrower:
sts=issued sub=tester act=planner-agent scope='buckets:copy buckets:read' dropped=buckets:delete ttl=120 task_id=46
sts=issued sub=tester act='executor-agent via planner-agent' scope='buckets:copy buckets:read' dropped=- ttl=119 task_id=46
Look at dropped=buckets:delete. The planner asked for read, copy and delete. I configured it to be greedy on purpose, because that’s what agents do when you let them pick their own scopes. The STS grants the intersection: what was requested, what the human’s roles allow (tester is a migrator), and the most this agent may ever carry. The agent’s appetite doesn’t matter. The human’s authority is the ceiling.
Note also ttl=119 on the second hop: a hop can shorten a permit, never extend it. Here is what the executor ends up holding:
{
"sub": "tester",
"aud": "mcp-gateway",
"scope": "buckets:copy buckets:read",
"act": {
"sub": "spiffe://highway.lab/ns/migration/sa/executor-agent",
"act": { "sub": "spiffe://highway.lab/ns/migration/sa/planner-agent" }
},
"task_id": "46",
"exp": 1790322398
}
That nested act is the chain of custody Part 5 will audit: tester, via the planner, via the executor.

Why I wrote an STS instead of using Keycloak’s
Keycloak 26.7.4 does standard token exchange well for downscoping audience and scope. Delegation (actor_token in, act out) exists as token-exchange-delegation, but it’s experimental. It’s single-hop (multi-hop is tracked for 26.8), and it’s modeled on an admin impersonating a user: the actor needs the impersonation role and the user sees a consent screen. Giving an agent an impersonation role, in a post about least privilege, felt like the wrong lesson. SPIFFE client authentication is still preview.
So Keycloak stays the human IdP, and the lab’s permit office is about 150 lines of Python. It has one rule worth copying: no actor_token, no permit. It delegates; it never impersonates. The may_act decision (the planner may act for humans, the executor only for the planner) lives in the STS, not in the token.
The traffic law lives at the gateway
The MCP gateway is the enforcement point. It verifies two tokens on every request: the permit, and a fresh JWT-SVID from whoever is presenting it. Then it hands OPA the verified claims plus the JSON-RPC call, and OPA decides. The Python side does the cryptography, and the Rego side makes the decision:
# practice/part3/policy/mcp.rego (abridged)
# 2. Sender constraint: a permit only works for the agent it was issued to.
deny contains msg if {
input.permit
input.caller
input.caller != actor
msg := sprintf("caller %s is not the permit's actor %s", [short(input.caller), short(actor)])
}
# 3. Delegated scope: the human must hold it, and the permit must carry it.
deny contains msg if {
input.method == "tools/call"
need := tool_scope[input.tool]
not need in scopes
msg := sprintf("scope %s not in permit", [need])
}
# 5. Resource rules that no scope overrides.
deny contains msg if {
input.tool == "delete_bucket"
input.args.bucket in data.lab.retention_locked
msg := sprintf("%s is under retention lock", [input.args.bucket])
}
Now replay Part 2’s ending: same poisoned inventory note, same validly signed delete.
task part3:poison
executor=task task_id=47 step=2 instruction='delete bucket prod-archive' requested_by=tester signer=spiffe://highway.lab/ns/migration/sa/planner-agent
gateway=DENY method=tools/call tool=delete_bucket sub=tester act='executor-agent via planner-agent' caller=executor-agent task_id=47 reason='prod-archive is under retention lock; scope buckets:delete not in permit'
The model still followed the note. The planner still signed it. The executor still tried. The difference is that requested_by=tester now comes from a verified permit, and the permit never carried delete. Both copies ran; the delete didn’t.
Two reasons fired, and that bothered me at first: which layer did the work? So the lab runs it again as ops-admin, who does hold buckets:delete:
sts=issued sub=ops-admin act=planner-agent scope='buckets:copy buckets:delete buckets:read' dropped=- ttl=120 task_id=48
gateway=DENY method=tools/call tool=delete_bucket sub=ops-admin ... reason='prod-archive is under retention lock'
Scope is necessary, not sufficient. Some resources no permit should touch, however senior the human.
A permit in a queue is a bearer token in public
The permit travels inside the signed task, through Redis. Redis still has no authz, on purpose. So the rogue reads one:
task part3:steal
forge=stolen task_id=49 sub=tester act=planner-agent scope='buckets:copy buckets:read'
gateway=DENY method=initialize tool=- sub=tester act=planner-agent caller=- task_id=49 reason='no actor token (JWT-SVID)'
This is the design decision I’d defend hardest. Short TTLs are not the defense against theft; sender constraint is. 120 seconds is a long time for an attacker already on the queue. What makes the stolen permit worthless is that the gateway demands a JWT-SVID from the exact agent named in act.sub, and the rogue can’t get one. Treat a permit that isn’t bound to its presenter as a password left on the table.
What actually went wrong
- A denial that looked like an outage. My first gateway returned a tool error for denied calls, the way MCP expects. The executor crashed on it with an
ExceptionGroup. The MCP Python SDK 2.2 client negotiates the 2026-07-28 protocol, whose schema requiresresultType: "complete"on a tool result. My synthetic denial didn’t have it. A policy deny that crashes the client isn’t a deny; it’s a denial of service you wrote yourself. The fix was one field. Finding it took longer than writing the policy. - Every task step now costs a token exchange. Three steps, four STS calls, plus Keycloak, OPA and two gateways. That’s real latency and five more things to keep up. For an agent with delete rights in a regulated environment, I’ll pay it. For a summarizer, I wouldn’t.
- The permit is checked at the gate. Nothing forces traffic through the gate.
task part3:bypass
forge=bypass target=http://127.0.0.1:8000/mcp result='{"result": "deleted prod-archive"}'
tool=delete_bucket bucket=prod-archive status=ok peer=- sub=-
The rogue never went near the gateway. It called mcp-tools:8000 directly and the bucket is gone, with sub=- in the log. Every check in this post ran on a road the attacker didn’t take.
The road ahead
- The Safe Highway: mTLS, and its limits.
- License Plates: attested workload identity, and signed payloads.
- Driving Permits (this post): delegation (
sub+act), scopes, TTL, and policy at the gateway. The LLM key leaves the agents. - The Closed Track: the fence. Egress NetworkPolicy so the gateway is the only road, a gVisor sandbox, and a blast-radius report.
- The Black Box: an audit chain that names the human, the agent and the task. The
actchain above is its raw material.
The lab is practice/part3 in agentic-highway-labs. task part3:all runs it on k3d. The output quoted in this post comes from running the same code as local processes against real SPIRE 1.15.3, Keycloak 26.7.4 and OPA 1.20.1 (captured/local). There’s no mesh in that run, so peer= shows -. LAB_USER=hagzag task part3:permit logs in as someone else. opa test practice/part3/policy runs the policy tests without a cluster.
Conclusion
Parts 1 and 2 answered which workload and who wrote the instruction. A permit answers the questions an auditor asks next: on whose behalf, through which agents, allowed to do what, until when. The answers come from a human’s login and an exchange at every hop, not from a field the agent fills in.
Just don’t mistake the permit for the fence. A checkpoint only stops the cars that drive through it.
Discussion