diff --git a/src/gateway.ts b/src/gateway.ts index 33eaee2..a45c0c9 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -174,6 +174,28 @@ const RISK_REQUIRED_SCOPES: Record = { DESTRUCTIVE: ['generate'], }; +// ─── Scope-exempt tools ──────────────────────────────────────── +// Tools in this set are callable by any valid authenticated bearer token +// regardless of scope grants. Criteria: zero cost (baseCost=0), no side +// effects, no external calls, and pure read/heuristic operation. +// +// scaffold_classify: heuristic keyword matching — no LLM, no quota, +// no backend state mutation. Classification is read-only by nature. +// Requiring 'generate' scope here blocks use from read-only API keys +// and OAuth tokens minted without 'read' scope, with no security benefit +// (there is nothing to protect; the operation is free and side-effect-free). +// +// scaffold_status: health/version probe — no side effects. +// image_list_models: static model catalog read — no side effects. +// +// Note: image_check_job is NOT exempt — it reads tenant-scoped job state +// and should continue to require at least 'read' scope. +const SCOPE_EXEMPT_TOOLS = new Set([ + 'scaffold_classify', + 'scaffold_status', + 'image_list_models', +]); + // ─── Tier-based access control ───────────────────────────────── // Restrict expensive quality tiers to users whose plan covers them. const TIER_ALLOWED_QUALITY: Record> = { @@ -1224,8 +1246,15 @@ async function handlePost(request: Request, env: GatewayEnv, oauthProps?: OAuthP // any client could register with claimed scopes and call any tool. // Now session.scopes (sourced from the actual token grant) must cover // the tool's risk level. + // + // Exception: SCOPE_EXEMPT_TOOLS (e.g. scaffold_classify, scaffold_status, + // image_list_models) are callable by any valid authenticated token. + // These are zero-cost, side-effect-free operations where scope + // enforcement adds friction with no security benefit. const requiredScopes = RISK_REQUIRED_SCOPES[risk]; - const hasRequiredScope = requiredScopes.some(s => session.scopes.includes(s)); + const hasRequiredScope = + SCOPE_EXEMPT_TOOLS.has(toolName) || + requiredScopes.some(s => session.scopes.includes(s)); if (!hasRequiredScope) { audit({ trace_id: traceId, diff --git a/test/gateway-legacy-scope.test.ts b/test/gateway-legacy-scope.test.ts index a8af1a0..846f1f7 100644 --- a/test/gateway-legacy-scope.test.ts +++ b/test/gateway-legacy-scope.test.ts @@ -198,11 +198,14 @@ describe('#29 legacy-scope fallback in resolveAuth', () => { const initBody = await initRes.json() as { result: { serverInfo: { metadata: { session: { scopes: string[] } } } } }; expect(initBody.result.serverInfo.metadata.session.scopes).toEqual([]); - // tools/call should fail with the (none) scopes message — same shape the - // reporter hit, and the UX signal that points the user to reauth. + // tools/call should fail with the (none) scopes message for a mutation tool + // — same shape the reporter hit, and the UX signal that points the user to + // reauth. Use scaffold_create (LOCAL_MUTATION) since scope-exempt tools + // (scaffold_classify, scaffold_status, image_list_models) are deliberately + // callable without scope to avoid blocking zero-cost read-only operations. const callReq = rpcRequest( 'tools/call', - { name: 'scaffold_status', arguments: {} }, + { name: 'scaffold_create', arguments: { intention: 'build an API' } }, { 'MCP-Session-Id': sessionId }, ); const callRes = await handleMcpRequest(callReq, env, oauthProps); diff --git a/test/gateway.test.ts b/test/gateway.test.ts index 64f9982..01e1e01 100644 --- a/test/gateway.test.ts +++ b/test/gateway.test.ts @@ -426,7 +426,7 @@ describe('handleMcpRequest', () => { expect(body.result?.content?.[0]?.text).toBe('generated'); }); - it('denies all tool calls when token has no scopes', async () => { + it('denies mutation tool calls when token has no scopes', async () => { const env = makeEnv({ AUTH_SERVICE: { ...mockAuthService(), @@ -443,9 +443,10 @@ describe('handleMcpRequest', () => { const initRes = await handleMcpRequest(initReq, env); const sessionId = initRes.headers.get('MCP-Session-Id')!; + // scaffold_create is LOCAL_MUTATION — requires 'generate' scope, not exempt const req = rpcRequest( 'tools/call', - { name: 'scaffold_status', arguments: {} }, + { name: 'scaffold_create', arguments: { intention: 'build an API' } }, { 'MCP-Session-Id': sessionId }, ); const res = await handleMcpRequest(req, env); @@ -454,6 +455,91 @@ describe('handleMcpRequest', () => { expect(body.error).toBeTruthy(); expect(body.error.code).toBe(-32600); }); + + it('allows scaffold_classify with no scopes (scope-exempt)', async () => { + // scaffold_classify is zero-cost, side-effect-free heuristic matching. + // Any valid authenticated bearer token must be able to call it — no + // 'read' or 'generate' scope required. This is the fix for issue #51. + const env = makeEnv({ + AUTH_SERVICE: { + ...mockAuthService(), + validateApiKey: async () => ({ + valid: true, + tenant_id: 'tenant-1', + tier: 'free', + scopes: [], // no scopes at all + }), + }, + TAROTSCRIPT: { + fetch: async () => new Response(JSON.stringify({ + facts: { + classification: 'scaffold', + classification_confidence: 'high', + classification_executor: 'keyword', + classification_complexity: 'moderate', + secondary_classification: null, + compound_intent: null, + tiebreaker_override: null, + }, + output: ['classified'], + }), { headers: { 'Content-Type': 'application/json' } }), + connect: () => { throw new Error('not implemented'); }, + } as unknown as Fetcher, + }); + + const initReq = rpcRequest('initialize', { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test' } }); + const initRes = await handleMcpRequest(initReq, env); + const sessionId = initRes.headers.get('MCP-Session-Id')!; + + const req = rpcRequest( + 'tools/call', + { name: 'scaffold_classify', arguments: { message: 'build a REST API for a food delivery app' } }, + { 'MCP-Session-Id': sessionId }, + ); + const res = await handleMcpRequest(req, env); + const body = await res.json() as any; + + // Must NOT be a scope error + expect(body.error).toBeFalsy(); + expect(body.result).toBeTruthy(); + expect(body.result.isError).toBeFalsy(); + }); + + it('allows scaffold_classify with only read scope', async () => { + const env = makeEnv({ + AUTH_SERVICE: { + ...mockAuthService(), + validateApiKey: async () => ({ + valid: true, + tenant_id: 'tenant-1', + tier: 'free', + scopes: ['read'], + }), + }, + TAROTSCRIPT: { + fetch: async () => new Response(JSON.stringify({ + facts: { classification: 'scaffold', classification_confidence: 'high' }, + output: ['classified'], + }), { headers: { 'Content-Type': 'application/json' } }), + connect: () => { throw new Error('not implemented'); }, + } as unknown as Fetcher, + }); + + const initReq = rpcRequest('initialize', { protocolVersion: '2025-03-26', capabilities: {}, clientInfo: { name: 'test' } }); + const initRes = await handleMcpRequest(initReq, env); + const sessionId = initRes.headers.get('MCP-Session-Id')!; + + const req = rpcRequest( + 'tools/call', + { name: 'scaffold_classify', arguments: { message: 'what is the intent of this message?' } }, + { 'MCP-Session-Id': sessionId }, + ); + const res = await handleMcpRequest(req, env); + const body = await res.json() as any; + + expect(body.error).toBeFalsy(); + expect(body.result).toBeTruthy(); + }); }); describe('OAuth grant props (#34) — tenantId/tier baked in at mint', () => {