mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 22:46:32 +00:00
feat(mcp): plugins:use OAuth scope
One coarse scope rather than per-plugin granularity. Which plugins are
installed is runtime data, so a per-plugin scope cannot be statically
enumerated: it would break the ${group}:${mode} derivation and the
AssertExact guards in nest-mcp-policy.ts, and its consent copy could not
be written ahead of time for a third-party plugin name. Tokens issued
before a plugin existed could never carry its scope either. Per-plugin
granularity already exists one layer down as the admin's mcp:tools grant.
'use' is a fourth mode, which widens ScopeMode. Nothing else moves:
McpAccessGroup and McpAccessMode derive from Scope, so both AssertExact
guards self-heal, and the policy's fallback branch already covers a third
mode the way it covers files:content.
The presets needed an explicit exclusion. Five of the six are written as
"everything except deletes", so plugins:use would have joined Claude.ai,
Cursor, Windsurf and Zed silently and handed out third-party tool
execution without anyone choosing it. That filter was duplicated across
the desktop and mobile files with nothing pinning it, so both now use one
shared constant, and FE-OAUTH-SCOPES-012 holds the line for the next
scope like this one.
The consent copy says what the scope does not do: the RPC host authorises
from grants frozen at construction, so this scope plus weather:read still
reaches whatever the plugin itself was granted.
Two i18n gates, not one. The dot-only group key is tracked by
i18n-parity.mjs, whose regex has no colon in its character class; the
colon-bearing label/description pair is invisible to it but is compared by
client/tests/unit/i18n/parity.test.ts, which diffs merged bundles. All 23
locales get all three keys, including the three in neither gate.
The consent glyph gets a ':use' branch. Falling through to the eye would
tell the user a tool-running scope only looks at things.
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-011
|
||||
// FE-OAUTH-SCOPES-001 to FE-OAUTH-SCOPES-014
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup } from './oauthScopes'
|
||||
import { SCOPE_GROUPS, ALL_SCOPES, SCOPE_GROUP_NAMES, getScopesByGroup, PRESET_OPT_IN_ONLY, PRESET_SCOPES_DEFAULT, PRESET_SCOPES_READONLY } from './oauthScopes'
|
||||
|
||||
// The consent page mirrors the server's scope list by hand, so read the server
|
||||
// file and compare. Parsing keeps this a client-only test (no server import).
|
||||
@@ -31,6 +31,7 @@ describe('SCOPE_GROUPS', () => {
|
||||
'vacay:read', 'vacay:write',
|
||||
'geo:read', 'weather:read',
|
||||
'journey:read', 'journey:write', 'journey:share',
|
||||
'plugins:use',
|
||||
]
|
||||
for (const scope of expected) {
|
||||
expect(SCOPE_GROUPS).toHaveProperty(scope)
|
||||
@@ -123,3 +124,26 @@ describe('getScopesByGroup', () => {
|
||||
expect(groups['oauth.scope.group.trips']).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('client presets', () => {
|
||||
// The presets are written as "everything except deletes", so a new scope joins
|
||||
// them silently. That is right for a scope over the user's own data and wrong
|
||||
// for one that runs third-party code, so the exclusion gets a test.
|
||||
it('FE-OAUTH-SCOPES-012: never offers an opt-in-only scope by default', () => {
|
||||
expect(PRESET_OPT_IN_ONLY.size).toBeGreaterThan(0)
|
||||
for (const scope of PRESET_OPT_IN_ONLY) {
|
||||
expect(PRESET_SCOPES_DEFAULT).not.toContain(scope)
|
||||
expect(PRESET_SCOPES_READONLY).not.toContain(scope)
|
||||
}
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-013: still offers every other non-destructive scope', () => {
|
||||
const expected = ALL_SCOPES.filter(s => !s.includes(':delete') && !PRESET_OPT_IN_ONLY.has(s))
|
||||
expect(PRESET_SCOPES_DEFAULT).toEqual(expected)
|
||||
expect(PRESET_SCOPES_DEFAULT).toContain('trips:write')
|
||||
})
|
||||
|
||||
it('FE-OAUTH-SCOPES-014: the read-only preset stays read-only', () => {
|
||||
expect(PRESET_SCOPES_READONLY.every(s => s.endsWith(':read'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -48,10 +48,27 @@ export const SCOPE_GROUPS: Record<string, ScopeKeys> = {
|
||||
'files:content': { labelKey: 'oauth.scope.files:content.label', descriptionKey: 'oauth.scope.files:content.description', groupKey: 'oauth.scope.group.files' },
|
||||
'settings:read': { labelKey: 'oauth.scope.settings:read.label', descriptionKey: 'oauth.scope.settings:read.description', groupKey: 'oauth.scope.group.settings' },
|
||||
'settings:write': { labelKey: 'oauth.scope.settings:write.label', descriptionKey: 'oauth.scope.settings:write.description', groupKey: 'oauth.scope.group.settings' },
|
||||
'plugins:use': { labelKey: 'oauth.scope.plugins:use.label', descriptionKey: 'oauth.scope.plugins:use.description', groupKey: 'oauth.scope.group.plugins' },
|
||||
}
|
||||
|
||||
export const ALL_SCOPES = Object.keys(SCOPE_GROUPS)
|
||||
|
||||
/**
|
||||
* Scopes a client preset must never tick for you.
|
||||
*
|
||||
* The presets are written as "everything except deletes", so a new scope joins
|
||||
* them silently. That is right for a new read or write scope on data the user
|
||||
* already owns, and wrong for plugins:use: it turns on third-party tool
|
||||
* execution, which nobody would have chosen by installing an editor.
|
||||
*/
|
||||
export const PRESET_OPT_IN_ONLY = new Set(['plugins:use'])
|
||||
|
||||
/** The five full-access presets: everything destructive or opt-in-only removed. */
|
||||
export const PRESET_SCOPES_DEFAULT = ALL_SCOPES.filter(s => !s.includes(':delete') && !PRESET_OPT_IN_ONLY.has(s))
|
||||
|
||||
/** The read-only preset (VS Code). Kept beside the default so the pair stays visible. */
|
||||
export const PRESET_SCOPES_READONLY = ALL_SCOPES.filter(s => s.endsWith(':read') && !PRESET_OPT_IN_ONLY.has(s))
|
||||
|
||||
// Group all scopes for the client registration form
|
||||
export const SCOPE_GROUP_NAMES = [...new Set(Object.values(SCOPE_GROUPS).map(s => s.groupKey))]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user