Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,28 @@ const RISK_REQUIRED_SCOPES: Record<RiskLevel, readonly string[]> = {
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<string>([
'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<Tier, Set<string>> = {
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 6 additions & 3 deletions test/gateway-legacy-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
90 changes: 88 additions & 2 deletions test/gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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);
Expand All @@ -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', () => {
Expand Down
Loading