← All posts

Scoped tokens - giving an agent the minimum, and proving it holds

How Rezee scopes agent access tokens to capability and resource - the scope grammar, the single matcher that gates every tool call, and a 200,000-pair adversarial fuzz proving zero privilege escalations against an independent oracle.

Jun 15, 2026 · 7 min read · Kash Gohil

Rezee is one workspace for the whole product development lifecycle - code, planning, CI/CD, chat, and docs. When an agent acts through the tool surface, the question that decides whether you can safely delegate is not what can this agent do but what can this agent's token do - because an agent is a user and its power is exactly its credential's power. Today, a Rezee access token is unscoped: it acts as its whole user, everywhere. This post is the scoped-token model that replaces that - the grammar, the single matcher that gates every call, and a fuzz test that proves the matcher grants no more than it should.

What's wrong with today's token?

Our access_tokens table is honest about its current limitation - there's no scope column:

export const accessTokens = pgTable("access_tokens", {
	id: uuid("id").primaryKey().defaultRandom(),
	userId: uuid("user_id").notNull().references(() => users.id, ...),
	name: text("name").notNull(),
	tokenHash: text("token_hash").notNull().unique(),
	lastUsedAt: timestamp("last_used_at", ...),
	expiresAt: timestamp("expires_at", ...),
});

A token is a hashed secret bound to a user, and that's all. Present it and you are that user - every repo they can push, every issue they can close, every secret they can read. For a human running git push from their laptop, that's fine; it's their account. For an agent, it's a loaded gun: you wanted a bot that triages issues, and you handed it something that can force-push to main. We measured what that means concretely - against the 65 tools generated from our API, an unscoped token reaches all 65:

Tools reachable per token profile (of 65) Tools reachable per token profile (of 65) tools reachable 020406080 3420261965 read-onlytriagecodedocsunscoped PAT tools reachable

The scoped profiles in that chart - a triage bot at 20 tools, a docs writer at 19 - are the point. Least privilege isn't a nice-to-have for a non-human principal that runs unattended; it's the difference between "the agent had a bad day" and "the agent had a bad day with your production repo."

What does a scope look like?

The M9 addition is one column - scopes text[] on access_tokens - and a small grammar. A scope is resource:action, ordered least-to-most powerful:

issues:read      one capability on one resource
issues:write     write implies read on the same resource
issues:*         both actions on one resource
*:read           read on every resource
*                everything (an unscoped token - today's default)
repo:acme/web    a resource-instance binding (a specific repo)

A token carries a set of these. The triage bot's set is ["issues:write", "chat:write", "pull_requests:read"] - it can file and comment on issues, post in channels, and read PRs, and it structurally cannot merge one. The scopes are the same capability strings the tool generator derives per endpoint, which is what lets the tool surface and the permission surface line up: every tool has a required scope, every token has a granted set, and a call is allowed iff the set grants the requirement.

The matcher is the whole security boundary

One function decides every call, so it's worth reading in full:

function actionGrants(granted: string, required: string): boolean {
	if (granted === "*") return true;
	if (granted === required) return true;
	if (granted === "write" && required === "read") return true; // write ⊇ read
	return false;
}

export function scopeGrants(granted: string, required: string): boolean {
	const [gRes, gAct = "*"] = granted.split(":");
	const [rRes, rAct] = required.split(":");
	const resOk = gRes === "*" || gRes === rRes;
	return resOk && actionGrants(gAct, rAct);
}

export function grants(tokenScopes: string[], required: string): boolean {
	return tokenScopes.some((s) => scopeGrants(s, required));
}

The two rules that carry real weight are * matching any resource and write implying read (you can always read what you may write, so issues:write alone lets an agent read issues to decide how to comment). Everything else is exact match. Deliberately small: the more clever a permission matcher is, the more likely it grants something you didn't mean.

Crucially, this runs at the same chokepoint that already answers "can this identity touch this repo?" - the authorize step every request passes through. Scope enforcement is one more check layered after identity and workspace role, not a parallel system. A token that clears the role check still has to clear its scope for the specific action, and it fails closed: no matching scope is a 403 before the handler runs.

How do you know the matcher is right?

A permission matcher that's subtly too permissive is the worst kind of bug - invisible until someone exploits it - so we don't trust it by reading it. We fuzz it against an independent oracle: a second implementation, written differently (it expands each scope into the concrete set of (resource, action) pairs it covers and checks membership), so a bug in one is unlikely to be mirrored in the other.

The harness (scripts/bench/scoped-tokens) generates random token scope-sets and checks every one against a randomly chosen tool, comparing the fast matcher's verdict to the oracle's:

fuzz: 200,000 random (token, tool) pairs
  privilege escalations (matcher too permissive): 0
  over-denials (matcher too strict):              0
  SOUND: matcher matches the oracle exactly

Zero escalations across 200,000 pairs is the number that matters: not once did the matcher allow a call the oracle says it shouldn't. Zero over-denials matters too - a matcher that's too strict silently breaks legitimate agents, which is how you end up whitelisting * "to make it work" and losing the whole benefit. The two failure directions are both caught because the oracle is the ground truth for both.

The fuzz uses a seeded PRNG, so the run is identical every time - a regression in the matcher fails the exact same way for anyone who runs it, which is the point of committing the harness rather than just the result.

What doesn't the scope model solve?

The honest boundary, because a permission system you over-trust is worse than none:

  • Resource-instance binding is coarser than it looks. repo:acme/web scopes a token to one repo, but issues:write scopes to all issues in the workspace, not a project or a label. Finer-grained resource binding (this agent may only touch issues in this project) is real work we haven't modeled here; the grammar has room for it, the enforcement doesn't yet.
  • Scopes gate, they don't judge. A token scoped to issues:write can file a hundred garbage issues - each call is authorized. Scopes bound the kind of action, not its wisdom; that's what the human-in-the-loop gate is for on irreversible actions, and what rate limits are for on reversible ones.
  • Scope sets can sprawl. Give every agent a hand-tuned scope list and you get a permissions mess no one audits. The fix is named roles (triage, code, docs) that expand to scope sets - the profiles in the chart - so humans reason about roles and the matcher reasons about scopes.
  • A leaked scoped token is still a leaked token. Scoping bounds the blast radius; it doesn't prevent the leak. Short expiry and instant revocation (the token is a DB row, delete it) are the other half, and they apply to scoped and unscoped tokens alike.

FAQ

Why put scopes on the token instead of on the agent?

Because one agent may hold several tokens for different jobs, and you want to revoke or re-scope a capability without touching the agent's identity or its history. The token is the credential; the agent is the principal. Scoping the credential means "this key opens these doors," and you can cut a key without changing who the person is - the same reason agents are users but their power is their token's.

Doesn't write implying read widen scopes more than intended?

It's intentional and it's the only implication in the grammar. An agent that can comment on an issue must be able to read that issue to write a coherent comment; splitting them would force every write-scoped token to also carry the matching read scope, which is pure noise that people would get wrong. Every other pair is exact-match, so the blast radius of the one implication is exactly "read is a subset of write on the same resource," which is what everyone means anyway.

How is this enforced for external agents vs first-party ones?

Identically, which is the whole design. An external agent over MCP and a first-party beam agent both present a scoped token, and both hit the same authorize chokepoint that runs the same matcher. There is no path where a first-party agent skips the scope check - the check is in the API every tool call goes through, not in the client.