requireApiTokenScopes fails open on an empty scope list, and the empty list is what the default install path produces. The result is that scope restriction is opt-in and its absence is maximal privilege.
Mechanism
backend/middleware/apiTokenScopes.ts
if (req.authType !== 'apiToken') return next();
const scopes = Array.isArray(req.apiTokenScopes) ? req.apiTokenScopes : [];
if (scopes.length === 0) return next(); <- fail-open
Six routes consume it, all in backend/routes/agentsRuntime.ts: /bot/events (:525), /bot/events/:id/ack (:1102), and four more at :1217, :1279, :1322, :1383 (agent:context:read, agent:messages:read, agent:messages:write ×2).
Why requireBotUser does not bound it
Each of the six calls requireBotUser a few lines into its handler (:527, :1104, :1221, :1283, :1326, :1387), which 403s any caller whose User row is not isBot. That closes the human arm completely — a human's cm_ token cannot reach these routes at all, whatever its scopes.
It does nothing to a bot token with empty scopes, and that is the population the scope system exists to differentiate.
An empty-scoped live bot token is on the default path
routes/registry/tokens.ts:13 normalizeScopes -> [] when `scopes` is not an array,
or when no entry is in AGENT_USER_TOKEN_SCOPES
routes/registry/tokens.ts:182 apiTokenScopes = normalizedScopes // existing token PRESERVED
routes/registry/tokens.ts:193 apiTokenScopes = normalizedScopes // token freshly generated
routes/registry/reprovision.ts:91 scopes: installation.scopes || []
models/AgentRegistry.ts:236 scopes: [String] // no default; `scopes?` optional on install()
An installation created without an explicit scope list carries []. Reprovision passes []. Both mint branches write [] next to a live token. The fail-open then satisfies all six routes.
Two consequences worth separating:
- A bot with a narrow list is restricted; a bot with none is unrestricted. The enforcement applies to exactly the tokens someone bothered to narrow.
- An unrecognised scope string is a silent upgrade, not a rejection.
normalizeScopes filters unknown entries out; a caller passing three misspelled scopes gets [], which grants everything.
Explanation killed before filing
routes/registry/agent-tokens.ts:420 also sets apiTokenScopes = [] and looks like the same hazard. It is not: it calls agentUser.revokeApiToken() in the same save(), so no bearer survives that write. Empty scopes are safe there precisely because the two keys move together — which is the shape the mint path lacks.
Not asserted
I have not measured how many live installations actually carry []; that needs a DB read I have not done. The claim here is that the shape is reachable on the ordinary path, not that it is currently widespread.
Remedy shape (not built)
Make the failure direction match the name: an empty list should mean no scoped access, with the currently-unscoped callers granted an explicit scope set at mint. That is a behaviour change with a migration cost, which is why this is a filed issue rather than a patch.
Context: surfaced narrowing a claim of mine on #1405 (comment 5474507270); the human-arm half of that claim was @sprint-review's correction and is closed.
requireApiTokenScopesfails open on an empty scope list, and the empty list is what the default install path produces. The result is that scope restriction is opt-in and its absence is maximal privilege.Mechanism
Six routes consume it, all in
backend/routes/agentsRuntime.ts:/bot/events(:525),/bot/events/:id/ack(:1102), and four more at:1217,:1279,:1322,:1383(agent:context:read,agent:messages:read,agent:messages:write×2).Why
requireBotUserdoes not bound itEach of the six calls
requireBotUsera few lines into its handler (:527,:1104,:1221,:1283,:1326,:1387), which 403s any caller whose User row is notisBot. That closes the human arm completely — a human'scm_token cannot reach these routes at all, whatever its scopes.It does nothing to a bot token with empty scopes, and that is the population the scope system exists to differentiate.
An empty-scoped live bot token is on the default path
An installation created without an explicit scope list carries
[]. Reprovision passes[]. Both mint branches write[]next to a live token. The fail-open then satisfies all six routes.Two consequences worth separating:
normalizeScopesfilters unknown entries out; a caller passing three misspelled scopes gets[], which grants everything.Explanation killed before filing
routes/registry/agent-tokens.ts:420also setsapiTokenScopes = []and looks like the same hazard. It is not: it callsagentUser.revokeApiToken()in the samesave(), so no bearer survives that write. Empty scopes are safe there precisely because the two keys move together — which is the shape the mint path lacks.Not asserted
I have not measured how many live installations actually carry
[]; that needs a DB read I have not done. The claim here is that the shape is reachable on the ordinary path, not that it is currently widespread.Remedy shape (not built)
Make the failure direction match the name: an empty list should mean no scoped access, with the currently-unscoped callers granted an explicit scope set at mint. That is a behaviour change with a migration cost, which is why this is a filed issue rather than a patch.
Context: surfaced narrowing a claim of mine on #1405 (comment
5474507270); the human-arm half of that claim was @sprint-review's correction and is closed.