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
27 changes: 11 additions & 16 deletions scripts/orbit-browser-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down Expand Up @@ -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(),
Expand All @@ -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'
Expand All @@ -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();
Expand Down
98 changes: 32 additions & 66 deletions scripts/orbit-slice1-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,6 @@ let firstCredentialToken = '';

async function createAuthorizedMcpGrant(options: {
requestId: string;
requestedScopes: string[];
grantedScopes: string[];
now: number;
}): Promise<{
grantId: string;
Expand All @@ -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,
Expand All @@ -256,7 +255,6 @@ let firstCredentialToken = '';
{
agentId: sponsoredAgentId,
ticket: ticketBody.ticket,
scopes: options.grantedScopes,
},
authenticatedHeaders(sponsorCookies, true),
options.now + 1,
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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',
Expand All @@ -550,6 +552,7 @@ let firstCredentialToken = '';
id: string;
oauthClient: { id: string; label: string };
scopes: string[];
scopeBundleVersion: number;
issuedAt: number;
expiresAt: number;
};
Expand All @@ -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);

Expand All @@ -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;
Expand All @@ -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],
Expand All @@ -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',
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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', {
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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,
});

Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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;
Expand Down
6 changes: 3 additions & 3 deletions src/pages/dashboard.astro
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ import Icon from '../components/Icon.astro';
<aside class="context-rail dashboard-context">
<div class="context-sticky">
<p class="section-label">Bağlantı izni</p>
<h1 id="mcp-consent-title">Ajanını ChatGPT’ye bağla.</h1>
<p>İzin yalnız seçtiğin ajan ve aşağıda açıkça onayladığın kapsamlar için geçerlidir.</p>
<h1 id="mcp-consent-title">Ajanını istemciye bağla.</h1>
<p>Bağlantı yalnız seçtiğin ajan ve bu sürümün açıkça gösterilen izin paketi için geçerlidir.</p>
</div>
</aside>

Expand All @@ -76,7 +76,7 @@ import Icon from '../components/Icon.astro';
<button id="mcp-approve" class="dashboard-button primary" type="button">Bağlantıya izin ver</button>
<button id="mcp-deny" class="dashboard-button secondary" type="button">Reddet</button>
</div>
<p class="login-assurance"><Icon name="info" size={15} /> Ajanının uzun ömürlü API anahtarı ChatGPT’ye veya MCP sunucusuna verilmez.</p>
<p class="login-assurance"><Icon name="info" size={15} /> Ajanının uzun ömürlü API anahtarı istemciye veya MCP sunucusuna verilmez.</p>
</article>

<aside class="network-rail dashboard-rail">
Expand Down
Loading