From 56524cf561f35b3831aaedb499ee7fc792bb265f Mon Sep 17 00:00:00 2001 From: sametbasbug Date: Sat, 1 Aug 2026 02:50:58 +0300 Subject: [PATCH] Bundle Orbit MCP permissions Require the complete versioned MCP permission bundle, remove consent downscoping and checkboxes, mark legacy partial grants for reauthorization, and keep future capability additions from silently widening existing grants. --- scripts/orbit-browser-tests.mjs | 27 +++-- scripts/orbit-slice1-tests.ts | 98 ++++++------------- src/pages/dashboard.astro | 6 +- src/scripts/dashboard.js | 41 +++----- src/server/http/api.ts | 47 ++++++--- .../identity/mcp-authorization-scopes.ts | 32 ++++++ .../identity/mcp-authorization-ticket.ts | 7 ++ 7 files changed, 130 insertions(+), 128 deletions(-) diff --git a/scripts/orbit-browser-tests.mjs b/scripts/orbit-browser-tests.mjs index b1f48ca..247060e 100644 --- a/scripts/orbit-browser-tests.mjs +++ b/scripts/orbit-browser-tests.mjs @@ -206,6 +206,7 @@ if (errors.length === 0) { id: browserMcpAuthorizationRequestId, oauthClient: { id: 'chatgpt-browser-client', label: 'ChatGPT' }, scopes: ['feed:read', 'posts:write', 'replies:write'], + scopeBundleVersion: 1, issuedAt: Date.now(), expiresAt: Date.now() + 10 * 60 * 1000, }, @@ -692,11 +693,10 @@ if (errors.length === 0) { hash: location.hash, client: document.querySelector('#mcp-client-summary')?.textContent?.trim(), scope: document.querySelector('#mcp-scope-summary')?.textContent?.trim(), - scopeOptions: [...document.querySelectorAll('#mcp-scope-options input[data-mcp-scope]')].map((input) => ({ - scope: input.dataset.mcpScope, - checked: input.checked, - disabled: input.disabled, + scopeOptions: [...document.querySelectorAll('#mcp-scope-options .mcp-scope-option')].map((option) => ({ + text: option.textContent?.trim(), })), + scopeInputs: document.querySelectorAll('#mcp-scope-options input').length, options: [...document.querySelectorAll('#mcp-agent-select option')].map((option) => ({ value: option.value, label: option.textContent?.trim(), @@ -705,21 +705,16 @@ if (errors.length === 0) { })); check(consentState.hash === '', 'MCP owner consentinde ticket URL fragmentından temizlenmedi.'); check(consentState.client?.includes('ChatGPT'), 'MCP consent istemci adını göstermiyor.'); - check(consentState.scope?.includes('isteğe bağlı yazma'), 'MCP consent kapsam daraltma açıklamasını göstermiyor.'); - check(consentState.scopeOptions.length === 3, 'MCP consent istenen kapsamları ayrı seçenekler olarak göstermiyor.'); - const feedScope = consentState.scopeOptions.find((option) => option.scope === 'feed:read'); - const postScope = consentState.scopeOptions.find((option) => option.scope === 'posts:write'); - const replyScope = consentState.scopeOptions.find((option) => option.scope === 'replies:write'); - check(feedScope?.checked === true && feedScope?.disabled === true, 'MCP consent zorunlu feed:read kapsamını sabitlemiyor.'); - check(postScope?.checked === false && postScope?.disabled === false, 'MCP consent posts:write kapsamını varsayılan kapalı göstermiyor.'); - check(replyScope?.checked === false && replyScope?.disabled === false, 'MCP consent replies:write kapsamını varsayılan kapalı göstermiyor.'); + check(consentState.scope?.includes('tek izin paketi'), 'MCP consent tek paket politikasını açıklamıyor.'); + check(consentState.scope?.includes('yeniden onay'), 'MCP consent gelecek yeteneklerde yeniden onayı açıklamıyor.'); + check(consentState.scopeOptions.length === 3, 'MCP consent zorunlu kapsamları ayrı kartlarda göstermiyor.'); + check(consentState.scopeInputs === 0, 'MCP consent isteğe bağlı checkbox göstermeye devam ediyor.'); + check(consentState.scopeOptions.every((option) => option.text?.includes('zorunlu')), 'MCP consent tüm mevcut kapsamları zorunlu göstermiyor.'); check(consentState.options.length === browserAgents.length, 'MCP consent yönetilebilir ajan listesini eksik gösteriyor.'); check(consentState.options.some((option) => option.value === 'agent-selene'), 'MCP consent Selene seçimini sunmuyor.'); check(consentState.approveDisabled === false, 'MCP consent onay düğmesi kullanılabilir değil.'); await page.locator('#mcp-agent-select').selectOption('agent-selene'); - await page.locator('#mcp-scope-options input[data-mcp-scope="posts:write"]').check(); - await page.locator('#mcp-scope-options input[data-mcp-scope="replies:write"]').check(); await page.locator('#mcp-approve').click(); await page.waitForURL((url) => ( url.origin === 'https://mcp.orbit.sametbasbug.dev' @@ -732,8 +727,8 @@ if (errors.length === 0) { check(browserMcpAuthorizationBodies[0]?.agentId === 'agent-selene', 'MCP consent seçilen ajanı grant isteğine bağlamadı.'); check(browserMcpAuthorizationBodies[0]?.ticket === browserMcpTicket, 'MCP consent imzalı ticketı grant isteğine taşımadı.'); check( - JSON.stringify(browserMcpAuthorizationBodies[0]?.scopes) === JSON.stringify(['feed:read', 'posts:write', 'replies:write']), - 'MCP consent yalnız açıkça seçilen kapsamları grant isteğine taşımadı.', + !Object.hasOwn(browserMcpAuthorizationBodies[0] ?? {}, 'scopes'), + 'MCP consent istemci kontrollü scopes alanı göndermeye devam ediyor.', ); check(pageErrors.length === 0, `MCP owner consentinde sayfa hatası: ${pageErrors.join(' | ')}`); await context.close(); diff --git a/scripts/orbit-slice1-tests.ts b/scripts/orbit-slice1-tests.ts index 2518e8d..29f093a 100644 --- a/scripts/orbit-slice1-tests.ts +++ b/scripts/orbit-slice1-tests.ts @@ -229,8 +229,6 @@ let firstCredentialToken = ''; async function createAuthorizedMcpGrant(options: { requestId: string; - requestedScopes: string[]; - grantedScopes: string[]; now: number; }): Promise<{ grantId: string; @@ -243,7 +241,8 @@ let firstCredentialToken = ''; authorizationRequestId: options.requestId, oauthClientId: `chatgpt-${options.requestId}`, oauthClientLabel: 'ChatGPT', - scopes: options.requestedScopes, + scopes: ['feed:read', 'posts:write', 'replies:write'], + scopeBundleVersion: 1, }, { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, options.now, @@ -256,7 +255,6 @@ let firstCredentialToken = ''; { agentId: sponsoredAgentId, ticket: ticketBody.ticket, - scopes: options.grantedScopes, }, authenticatedHeaders(sponsorCookies, true), options.now + 1, @@ -266,7 +264,10 @@ let firstCredentialToken = ''; authorization: { id: string; scopes: string[] }; delegation: { code: string }; }; - assert.deepEqual(body.authorization.scopes, options.grantedScopes); + assert.deepEqual( + body.authorization.scopes, + ['feed:read', 'posts:write', 'replies:write'], + ); return { grantId: body.authorization.id, delegationCode: body.delegation.code, @@ -508,12 +509,13 @@ let firstCredentialToken = ''; assert.deepEqual(meBody.sponsoredAgents.map((agent) => agent.id), [sponsoredAgentId]); }); - test('MCP authorization uses CSRF, least privilege, one-time exchange and revocation', async () => { + test('MCP authorization uses CSRF, a versioned permission bundle, one-time exchange and revocation', async () => { const ticketRequest = { authorizationRequestId: mcpAuthorizationRequestId, oauthClientId: 'chatgpt-dynamic-client-001', oauthClientLabel: 'ChatGPT', scopes: ['feed:read', 'posts:write', 'replies:write'], + scopeBundleVersion: 1, }; const unauthenticatedList = await request('/v1/mcp/authorizations', {}, NOW + 47); @@ -535,7 +537,7 @@ let firstCredentialToken = ''; ); assert.equal(elevated.status, 400); const elevatedBody = await elevated.json() as { error: { code: string } }; - assert.equal(elevatedBody.error.code, 'invalid_mcp_authorization_scope'); + assert.equal(elevatedBody.error.code, 'invalid_mcp_authorization_scope_bundle'); const ticketResponse = await postJson( '/v1/mcp/authorization-tickets', @@ -550,6 +552,7 @@ let firstCredentialToken = ''; id: string; oauthClient: { id: string; label: string }; scopes: string[]; + scopeBundleVersion: number; issuedAt: number; expiresAt: number; }; @@ -561,6 +564,7 @@ let firstCredentialToken = ''; ticketBody.authorizationRequest.scopes, ['feed:read', 'posts:write', 'replies:write'], ); + assert.equal(ticketBody.authorizationRequest.scopeBundleVersion, 1); assert.equal(ticketBody.authorizationRequest.issuedAt, NOW + 49); assert.equal(ticketBody.authorizationRequest.expiresAt, NOW + 49 + 10 * 60 * 1000); @@ -580,7 +584,12 @@ let firstCredentialToken = ''; ); assert.equal(inspected.status, 200, await inspected.clone().text()); const inspectedBody = await inspected.json() as { - authorizationRequest: { id: string; oauthClient: { label: string }; scopes: string[] }; + authorizationRequest: { + id: string; + oauthClient: { label: string }; + scopes: string[]; + scopeBundleVersion: number; + }; manageableAgents: Array<{ id: string; handle: string; @@ -594,6 +603,7 @@ let firstCredentialToken = ''; inspectedBody.authorizationRequest.scopes, ['feed:read', 'posts:write', 'replies:write'], ); + assert.equal(inspectedBody.authorizationRequest.scopeBundleVersion, 1); assert.deepEqual( inspectedBody.manageableAgents.map((agent) => agent.id), [sponsoredAgentId], @@ -611,36 +621,23 @@ let firstCredentialToken = ''; ); assert.equal(tamperedInspect.status, 400); - const readOnlyTicket = await postJson( + const partialBundleTicket = await postJson( '/v1/mcp/authorization-tickets', { ...ticketRequest, - authorizationRequestId: 'chatgpt-read-only-request', + authorizationRequestId: 'chatgpt-partial-bundle-request', scopes: ['feed:read'], }, { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, NOW + 49, ); - assert.equal(readOnlyTicket.status, 201, await readOnlyTicket.clone().text()); - const readOnlyTicketBody = await readOnlyTicket.json() as { ticket: string }; - const widenedGrant = await postJson( - '/v1/mcp/authorizations', - { - agentId: sponsoredAgentId, - ticket: readOnlyTicketBody.ticket, - scopes: ['feed:read', 'posts:write'], - }, - authenticatedHeaders(sponsorCookies, true), - NOW + 50, - ); - assert.equal(widenedGrant.status, 400); - const widenedGrantBody = await widenedGrant.json() as { error: { code: string } }; - assert.equal(widenedGrantBody.error.code, 'invalid_mcp_authorization_scope'); + assert.equal(partialBundleTicket.status, 400); + const partialBundleBody = await partialBundleTicket.json() as { error: { code: string } }; + assert.equal(partialBundleBody.error.code, 'invalid_mcp_authorization_scope_bundle'); const authorizationBody = { agentId: sponsoredAgentId, ticket: ticketBody.ticket, - scopes: ['feed:read'], }; const noCsrf = await postJson( '/v1/mcp/authorizations', @@ -686,7 +683,7 @@ let firstCredentialToken = ''; mcpDelegationCode = createdBody.delegation.code; assert.ok(mcpGrantId); assert.ok(mcpDelegationCode.startsWith('orb_mcp_v1_')); - assert.deepEqual(createdBody.authorization.scopes, ['feed:read']); + assert.deepEqual(createdBody.authorization.scopes, ['feed:read', 'posts:write', 'replies:write']); assert.equal(createdBody.authorization.agent.id, sponsoredAgentId); assert.equal(createdBody.authorization.oauthClient.label, 'ChatGPT'); assert.equal(createdBody.authorization.status, 'active'); @@ -730,7 +727,7 @@ let firstCredentialToken = ''; authorization: { id: string; scopes: string[]; status: string }; }; assert.equal(redeemedBody.authorization.id, mcpGrantId); - assert.deepEqual(redeemedBody.authorization.scopes, ['feed:read']); + assert.deepEqual(redeemedBody.authorization.scopes, ['feed:read', 'posts:write', 'replies:write']); assert.equal(redeemedBody.authorization.status, 'active'); const replay = await postJson('/v1/mcp/delegations/redeem', { @@ -786,7 +783,10 @@ let firstCredentialToken = ''; }; }; assert.equal(privateStateBody.authorization.id, mcpGrantId); - assert.deepEqual(privateStateBody.authorization.scopes, ['feed:read']); + assert.deepEqual( + privateStateBody.authorization.scopes, + ['feed:read', 'posts:write', 'replies:write'], + ); assert.equal(privateStateBody.authorization.lastUsedAt, NOW + 56); assert.equal(privateStateBody.agent.id, sponsoredAgentId); assert.equal(privateStateBody.agent.handle, 'selene-test-agent'); @@ -865,24 +865,9 @@ let firstCredentialToken = ''; assert.equal(expiredTicket.status, 400); }); - test('MCP delegated writes enforce independent scopes, idempotency and revocation', async () => { - const requestedScopes = ['feed:read', 'posts:write', 'replies:write']; - const postOnly = await createAuthorizedMcpGrant({ - requestId: 'chatgpt-post-only-request', - requestedScopes, - grantedScopes: ['feed:read', 'posts:write'], - now: NOW + 1_000, - }); - const replyOnly = await createAuthorizedMcpGrant({ - requestId: 'chatgpt-reply-only-request', - requestedScopes, - grantedScopes: ['feed:read', 'replies:write'], - now: NOW + 1_010, - }); + test('MCP delegated writes enforce the full bundle, idempotency and revocation', async () => { const full = await createAuthorizedMcpGrant({ requestId: 'chatgpt-write-request', - requestedScopes, - grantedScopes: requestedScopes, now: NOW + 1_020, }); @@ -916,26 +901,6 @@ let firstCredentialToken = ''; const missingKeyBody = await missingKey.json() as { error: { code: string } }; assert.equal(missingKeyBody.error.code, 'idempotency_key_required'); - const postDenied = await postJson( - `/v1/mcp/grants/${encodeURIComponent(replyOnly.grantId)}/records`, - mcpWritePostBody, - mcpServiceHeaders('mcp-post-scope-denied'), - NOW + 1_032, - ); - assert.equal(postDenied.status, 403); - const mcpPostDeniedBody = await postDenied.json() as { error: { code: string } }; - assert.equal(mcpPostDeniedBody.error.code, 'mcp_authorization_scope_denied'); - - const replyDenied = await postJson( - `/v1/mcp/grants/${encodeURIComponent(postOnly.grantId)}/records/not-needed/replies`, - { bodyMarkdown: 'Bu yanıt kapsam nedeniyle reddedilmeli.' }, - mcpServiceHeaders('mcp-reply-scope-denied'), - NOW + 1_033, - ); - assert.equal(replyDenied.status, 403); - const replyDeniedBody = await replyDenied.json() as { error: { code: string } }; - assert.equal(replyDeniedBody.error.code, 'mcp_authorization_scope_denied'); - const mediaDenied = await postJson( `/v1/mcp/grants/${encodeURIComponent(full.grantId)}/records`, { ...mcpWritePostBody, mediaId: '019f64d2-0109-7644-9a4e-a0d25df888e2' }, @@ -1211,7 +1176,8 @@ let firstCredentialToken = ''; authorizationRequestId: 'foreign-authorization-request', oauthClientId: 'foreign-client', oauthClientLabel: 'Foreign client', - scopes: ['feed:read'], + scopes: ['feed:read', 'posts:write', 'replies:write'], + scopeBundleVersion: 1, }, { authorization: `Bearer ${MCP_SERVICE_SECRET}` }, NOW + 54); assert.equal(foreignTicketResponse.status, 201); const foreignTicket = (await foreignTicketResponse.json() as { ticket: string }).ticket; diff --git a/src/pages/dashboard.astro b/src/pages/dashboard.astro index 50588ec..d5712b5 100644 --- a/src/pages/dashboard.astro +++ b/src/pages/dashboard.astro @@ -50,8 +50,8 @@ import Icon from '../components/Icon.astro'; @@ -76,7 +76,7 @@ import Icon from '../components/Icon.astro'; -

Ajanının uzun ömürlü API anahtarı ChatGPT’ye veya MCP sunucusuna verilmez.

+

Ajanının uzun ömürlü API anahtarı istemciye veya MCP sunucusuna verilmez.