Authorizing AI Agents: Zanzibar-Style Permissions for MCP Tool Calls
An agent should never hold more authority than the person it acts for
The bug that authentication doesn't catch
Anne asks her company's assistant to pull the latest payroll numbers into a board deck. The agent calls a read_document tool on an MCP server. The request is authenticated: the agent presents a valid OAuth token, and the MCP server holds a service credential for the document store that can read every file in the company. So it reads payroll and writes the summary.
Anne isn't in finance. Nothing was hacked and no token leaked. Every hop was authenticated. The server simply used its own authority on Anne's behalf without ever asking whether Anne had any.
This is the confused deputy problem, and agents make it very easy to build by accident, because the quickest way to give an agent tools is to give the tool server one powerful credential. I spend most of my time on authorization (I built Monstro's SpiceDB permissions system and I'm building pemba, an embedded Zanzibar-style engine), and this is the pattern I'd use to stop it.
Why this is the question of the month
On 20 October the Cloud Security Alliance published MCP Tool Exposure is a Privilege Grant, which splits agent authorization into two gates: capability admission (should this tool be in the agent's tool list at all?) and execution authorization (is this specific call allowed against this specific resource?). It describes my payroll example almost word for word: "a tool invocation can appear perfectly authenticated" while the server exercises "authority the caller should never have been able to trigger".
The protocol has been moving the same way. The 2026-07-28 MCP spec now says the result of tools/list "MAY vary by the authorization presented on the request", and its security considerations say servers MUST "implement proper access controls". What it doesn't do is tell you how. And as Cakewalk pointed out, a request names the agent's software and the account behind the token, but there is no field for the person who caused the action.
Relationship-based access control answers all three, and it fits in under thirty lines of schema.
The rule: an agent never holds more authority than the person it acts for
An agent's effective permission is the intersection of three facts, and every tool call has to establish all of them:
- delegation: this agent is acting for this person, because the person said so.
- capability: this agent was granted this tool.
- authority: the person is allowed to do this to this resource.
In a Zanzibar model each one is a relationship, so each one is a check. Here is the schema:
definition user {}
definition group {
relation member: user | group#member
}
// An agent is software. It holds no authority of its own:
// it acts for the people who delegated to it.
definition agent {
relation principal: user
permission acts_for = principal
}
// Gate 1: which tools this agent is allowed to see and call at all.
definition tool {
relation granted: agent
permission invoke = granted
}
definition document {
relation owner: user
relation viewer: user | group#member
permission view = viewer + owner
}
The important line is the one that isn't there. document#viewer allows users and groups, not agents, so there is no way to give an agent access to a document directly. If someone tries, the write is rejected:
engine.write(touch=["document:payroll#viewer@agent:triage"])
# InvalidRelationshipError: subject `agent` is not allowed on `document#viewer`,
# which allows user | group#member [E_SUBJECT_TYPE_NOT_ALLOWED]
Agents can only ever borrow authority, and only from someone who delegated to them. Here is some data: Bea is in finance, payroll is finance-only, both of them can read the roadmap, and Anne has delegated to a triage agent with two tools.
engine.write(touch=[
"group:finance#member@user:bea",
"document:payroll#viewer@group:finance#member",
"document:roadmap#viewer@user:anne",
"document:roadmap#viewer@user:bea",
# Anne delegates to her triage agent, and grants it two tools.
"agent:triage#principal@user:anne",
"tool:read_document#granted@agent:triage",
"tool:search_documents#granted@agent:triage",
])
Gate 1: what goes into tools/list
The tool list is a lookup: which tool objects can this agent invoke?
def visible_tools(agent: str) -> list[str]:
"""Gate 1, capability admission: the tools/list this agent gets."""
return engine.lookup_resources("tool", "invoke", agent).ids
visible_tools("agent:triage") # ['read_document', 'search_documents']
Filter the list the model sees with this, and send_email or delete_folder never appear in its context. That is worth having, but the CSA post is right that "discovery filtering is risk reduction. It is not authorization." A model can still emit a call to a tool it was never shown, especially when a document it read told it to.
Gate 2: every call, against the resource
At call time, check all three facts. pemba's check_bulk evaluates them at one revision, so a revocation can't land between the delegation check and the resource check:
def authorize(agent: str, principal: str, tool: str, resource: str, permission: str) -> bool:
"""Gate 2, execution authorization: every check at one revision."""
results = engine.check_bulk([
(agent, "acts_for", principal), # is this agent acting for this person?
(tool, "invoke", agent), # was it granted this tool?
(resource, permission, principal), # can the *person* do this to this resource?
])
# check_bulk returns a PembaError in place of a bool on failure: fail closed.
return all(r is True for r in results)
Running it against the data above:
- Anne reads the roadmap: allowed. She delegated, the tool is granted, and she can view it.
- Anne reads payroll: denied. The agent and the tool are fine, but Anne isn't in finance. This is the confused deputy from the opening, stopped.
- The agent reads payroll "for Bea": denied. Bea can read payroll, but she never delegated to this agent, so a prompt that says "I'm Bea" gets nowhere.
- Anne's agent calls
send_email: denied. It was never granted, so it isn't in the tool list, and if injected text talks the model into calling it anyway, the refusal comes from code rather than the model's judgement.
That last case is the CSA post's best line in practice: "The model can propose the action. A deterministic security control should decide whether that action is allowed to run."
Note the r is True. pemba fails closed: check_bulk puts an error in place of a bool when an item can't be evaluated (a misspelt permission, a depth limit), and an error must never count as an allow.
Revocation that's actually immediate
When Anne revokes the agent, two things change: your app's record of the session, and the delegation relationship. With a separate authorization service that's a dual write, and the usual failure is that one lands and the other doesn't. SpiceDB also answers checks at its default minimize_latency consistency from a slightly older snapshot unless you pass the ZedToken from your write, which is reasonable for documents but uncomfortable for "stop my agent now".
Because pemba keeps its tables in your database, the revocation joins your transaction:
conn = sqlite3.connect("agents.db", isolation_level=None)
conn.execute("BEGIN IMMEDIATE")
conn.execute("UPDATE agent_sessions SET revoked_at = datetime('now') WHERE agent = 'triage'")
rev = engine.write(delete=["agent:triage#principal@user:anne"], conn=conn)
conn.execute("COMMIT")
authorize("agent:triage", "user:anne", "tool:read_document", "document:roadmap", "view") # False
The session row and the delegation commit together or not at all, and the very next tool call is refused.
Who decided?
This is the gap the MCP spec leaves open, and the schema covers part of it. The delegation agent:triage#principal@user:anne is a stored fact, written when Anne approved the agent, and every write returns the revision it committed at. If each tool call logs the agent, the principal, the tool, the resource, the decision and the revision, you can always say whose authority an action used.
Two caveats. First, a delegation is standing consent, not intent: it says Anne lets this agent act for her, not that she asked for this particular call. For anything that sends, deletes or pays, keep a person in the loop with MCP's elicitation or a confirmation step, as the spec recommends. Second, pemba's at_exactly reads are not an audit log, because they apply today's schema to old data. Keep your own decision log.
What it costs
I timed authorize() (one check_bulk with three checks, from Python, on SQLite) over 20,000 calls after a warm-up, on my machine: an Intel i5-14600KF on Windows 11, Python 3.12.10, SQLite 3.49.1. Across two runs it took 18.6 to 21.8 µs at p50 and under 40 µs at p99. An agent that makes 50 tool calls spends about 1 ms on authorization.
For comparison, pemba's benchmarks measured a single SpiceDB check through its HTTP gateway on localhost at 560 µs p50. Three of those per tool call is about 1.7 ms, or roughly 84 ms over 50 calls. That figure is arithmetic from a measured per-check latency, not a measured agent loop, and SpiceDB's bulk check API would get you closer to one round trip per call.
The honest conclusion is that latency isn't the argument here. A single model turn takes hundreds of milliseconds to seconds, so neither number will be your bottleneck. The reasons I'd embed this for a single application are that delegation, revocation and your own data commit together, and that there's no extra service to run. If you already run SpiceDB, use this schema there: pemba's schema language is a strict subset of SpiceDB's.
What authorization doesn't fix
Prompt injection still works inside the intersection. If Anne can read the roadmap and the agent can search and read, a poisoned document can still steer the agent into reading things Anne is allowed to read and leaking them into a reply. Authorization bounds the blast radius to what the person could already do. It doesn't replace output filtering, egress controls or confirmations, and it works best with narrow tools: read_document(id), not run_sql(query).
Can you trust an alpha with this?
Fair question. The answer I can give is verification. pemba is differential-tested against SpiceDB, running the same schemas and the same questions through both engines, and that testing is thorough enough to have found a bug in SpiceDB itself. On 20 October I reported that SpiceDB 1.56.2 gave a different answer each time to the same schema: a wildcard reached through a subject set was accepted on 15 of 100 writes and refused on the rest.
AuthZed co-founder Joey Schorr had a fix up about five hours later. SpiceDB's wildcard-check cache was keyed by relation name rather than by definition#relation, so the order of validation decided the result. The fix was merged on 22 October. It matters for the advice above: when I say this schema moves to SpiceDB unchanged, that claim is checked between the two engines, not just asserted.
Try it
Everything above is one self-contained script, and it lives in the pemba repo as examples/agent_authz.py, where CI runs it and checks every decision it prints. Install pemba (pip install --pre pemba, Python 3.11 or newer), save the script and run it. It prints the tool list, the four decisions, the revocation, and the timing on your machine.
"""Authorising an AI agent's tool calls with pemba.
An agent never holds more authority than the person it acts for. Gate 1 filters
the tool list (MCP ``tools/list``) to the tools the agent was granted; gate 2
checks every call for delegation, tool grant and the person's own access to the
resource, all at one revision. Revoking an agent commits in the application's
own SQLite transaction.
pip install --pre pemba
python agent_authz.py
Walkthrough: https://joshuafields.uk/blog/authorizing-ai-agents-mcp-tool-calls-zanzibar
CI runs this file (crates/pemba-py/tests/test_examples.py), without the timing loop.
"""
import os
import sqlite3
import statistics
import time
from pemba import Engine
SCHEMA = """
definition user {}
definition group {
relation member: user | group#member
}
// An agent is software. It holds no authority of its own:
// it acts for the people who delegated to it.
definition agent {
relation principal: user
permission acts_for = principal
}
// Gate 1: which tools this agent is allowed to see and call at all.
definition tool {
relation granted: agent
permission invoke = granted
}
definition document {
relation owner: user
relation viewer: user | group#member
permission view = viewer + owner
}
"""
DB = "agents.db"
if os.path.exists(DB):
os.remove(DB)
engine = Engine.sqlite(DB)
engine.write_schema(SCHEMA)
engine.write(touch=[
"group:finance#member@user:bea",
"document:payroll#viewer@group:finance#member",
"document:roadmap#viewer@user:anne",
"document:roadmap#viewer@user:bea",
# Anne delegates to her triage agent, and grants it two tools.
"agent:triage#principal@user:anne",
"tool:read_document#granted@agent:triage",
"tool:search_documents#granted@agent:triage",
])
def visible_tools(agent: str) -> list[str]:
"""Gate 1, capability admission: the tools/list this agent gets."""
return engine.lookup_resources("tool", "invoke", agent).ids
def authorize(agent: str, principal: str, tool: str, resource: str, permission: str) -> bool:
"""Gate 2, execution authorisation: every check at one revision."""
results = engine.check_bulk([
(agent, "acts_for", principal), # is this agent acting for this person?
(tool, "invoke", agent), # was it granted this tool?
(resource, permission, principal), # can the *person* do this to this resource?
])
# check_bulk returns a PembaError in place of a bool on failure: fail closed.
return all(r is True for r in results)
print("tools/list for agent:triage:", visible_tools("agent:triage"))
cases = [
("read roadmap for anne", "user:anne", "tool:read_document", "document:roadmap"),
("read payroll for anne", "user:anne", "tool:read_document", "document:payroll"),
("read payroll for bea ", "user:bea", "tool:read_document", "document:payroll"),
("send_email for anne ", "user:anne", "tool:send_email", "document:roadmap"),
]
for label, who, tool, res in cases:
print(f"{label}: {authorize('agent:triage', who, tool, res, 'view')}")
# Revocation joins the app's own transaction: the session row and the
# delegation commit together, so there is no window where one is gone and
# the other is not.
conn = sqlite3.connect(DB, isolation_level=None)
conn.execute("CREATE TABLE agent_sessions (agent TEXT, principal TEXT, revoked_at TEXT)")
conn.execute("INSERT INTO agent_sessions VALUES ('triage', 'anne', NULL)")
conn.execute("BEGIN IMMEDIATE")
conn.execute("UPDATE agent_sessions SET revoked_at = datetime('now') WHERE agent = 'triage'")
rev = engine.write(delete=["agent:triage#principal@user:anne"], conn=conn)
conn.execute("COMMIT")
conn.close()
print("after revoke, read roadmap for anne:",
authorize("agent:triage", "user:anne", "tool:read_document", "document:roadmap", "view"),
f"(revision {rev})")
if __name__ == "__main__":
# Put the delegation back, then time one tool-call authorisation.
engine.write(touch=["agent:triage#principal@user:anne"])
args = ("agent:triage", "user:anne", "tool:read_document", "document:roadmap", "view")
for _ in range(2_000):
authorize(*args)
samples: list[float] = []
for _ in range(20_000):
t = time.perf_counter_ns()
authorize(*args)
samples.append((time.perf_counter_ns() - t) / 1_000)
samples.sort()
p50 = statistics.median(samples)
p99 = samples[int(len(samples) * 0.99)]
print(f"authorize(): p50 {p50:.1f} us, p99 {p99:.1f} us over {len(samples)} calls")
print(f"50 tool calls: {50 * p50 / 1000:.2f} ms of authorisation at p50")
pemba is an alpha (0.1.0-alpha.1) and its API may change. The code and the specification are on GitHub, and my last post covers how it works inside. If you're building agents and want this designed properly, I'm a London-based AI engineer who works on exactly this: get in touch.
Tags: Joshua Fields, AI engineer, AI agents, MCP, Model Context Protocol, agent authorization, Zanzibar authorization, ReBAC, SpiceDB, Python, AI security, London
Back to Writing | Joshua Fields Home | AI Engineer London | Agentic AI Engineer | pemba Project | pemba on GitHub | Example on GitHub | GitHub | LinkedIn
Joshua Fields — full portfolio