diff --git a/src/api/routes.ts b/src/api/routes.ts index 90c5f2fafe..ff2a9c4508 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -1504,6 +1504,14 @@ export function createApp() { app.get("/v1/repos/:owner/:repo/issue-quality", async (c) => { const fullName = `${c.req.param("owner")}/${c.req.param("repo")}`; + const identity = await authenticateRequestIdentity(c); + /* v8 ignore next -- Protected middleware rejects unauthenticated private routes before route-specific repo guards. */ + if (!identity) return c.json({ error: "unauthorized" }, 401); + const repo = identity.kind === "session" ? await getRepository(c.env, fullName) : null; + if (identity.kind === "session") { + const forbidden = await requireSessionRepoAccess(c, identity, fullName, repo); + if (forbidden) return forbidden; + } const response = await buildIssueQualityResponse(c.env, fullName); if (!response) return c.json({ error: "issue_quality_not_found", repoFullName: fullName }, 404); return c.json(response); @@ -3487,6 +3495,7 @@ function isExtensionScopedSession(identity: AuthIdentity): boolean { function canSessionAccessPath(env: Env, identity: Extract, path: string): boolean { if (isAuthorizedGitHubSessionLogin(env, identity.actor)) return true; if (path.startsWith("/v1/app/")) return true; + if (isIssueQualityPath(path)) return true; if (isRepoOnboardingPackPreviewPath(path)) return true; if (isRepoContributorIssueDraftGeneratePath(path)) return true; if (path === EXTENSION_PULL_CONTEXT_PATH && isExtensionScopedSession(identity)) return true; @@ -3501,6 +3510,10 @@ function isRepoContributorIssueDraftGeneratePath(path: string): boolean { return /^\/v1\/repos\/[^/]+\/[^/]+\/contributor-issue-drafts\/generate$/.test(path); } +function isIssueQualityPath(path: string): boolean { + return /^\/v1\/repos\/[^/]+\/[^/]+\/issue-quality$/.test(path); +} + async function authenticateRequestIdentity(c: ProtectedRouteContext): Promise { const bearer = await authenticatePrivateToken(c.env, extractBearerToken(c.req.header("authorization"))); if (bearer) return bearer; diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 0a4543c9b4..80e49e8e48 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -5,7 +5,7 @@ import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/proto import { ElicitResultSchema, type ServerNotification, type ServerRequest } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { authenticatePrivateToken, extractBearerToken, type AuthIdentity } from "../auth/security"; -import { loadControlPanelRoleSummary } from "../services/control-panel-roles"; +import { loadControlPanelAccessScope, loadControlPanelRoleSummary } from "../services/control-panel-roles"; import { countOpenIssues, countOpenPullRequests, @@ -862,6 +862,12 @@ export class GittensoryMcp { private async getIssueQuality(input: { owner: string; repo: string }): Promise { const fullName = `${input.owner}/${input.repo}`; + if (!(await this.canAccessRepo(fullName))) { + return { + summary: `Forbidden: session cannot access issue quality for ${fullName}.`, + data: { status: "forbidden", repoFullName: fullName }, + }; + } const response = await loadOrComputeIssueQualityResponse(this.env, fullName); if (!response) { return { @@ -878,6 +884,16 @@ export class GittensoryMcp { }; } + private async canAccessRepo(fullName: string): Promise { + if (this.identity.kind !== "session") return true; + const [summary, repo] = await Promise.all([loadControlPanelRoleSummary(this.env, this.identity.actor), getRepository(this.env, fullName)]); + if (summary.roles.includes("operator")) return true; + const scope = await loadControlPanelAccessScope(this.env, this.identity.actor); + const requestedRepo = fullName.toLowerCase(); + if (scope.repositoryFullNames.some((name) => name.toLowerCase() === requestedRepo)) return true; + return Boolean(repo && scope.accountLogins.some((login) => login.toLowerCase() === repo.owner.toLowerCase())); + } + private async getRepoOutcomePatterns(input: { owner: string; repo: string }): Promise { const fullName = `${input.owner}/${input.repo}`; const response = await loadOrComputeRepoOutcomePatternsResponse(this.env, fullName); diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index bc7e123804..9aef275212 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -1352,6 +1352,11 @@ describe("api routes", () => { report: { repoFullName: "entrius/allways-ui", issues: expect.any(Array) }, }); + const { token: unrelatedIssueQualityToken } = await createSessionForGitHubUser(env, { login: "unrelated-user", id: 404 }); + const forbiddenIssueQuality = await app.request("/v1/repos/entrius/allways-ui/issue-quality", { headers: { authorization: `Bearer ${unrelatedIssueQualityToken}` } }, env); + expect(forbiddenIssueQuality.status).toBe(403); + await expect(forbiddenIssueQuality.json()).resolves.toMatchObject({ error: "forbidden_repo" }); + await upsertRepositoryFromGitHub(env, { name: "uncached", full_name: "entrius/uncached", private: false, owner: { login: "entrius" }, default_branch: "main" }); const computedIssueQuality = await app.request("/v1/repos/entrius/uncached/issue-quality", { headers: apiHeaders(env) }, env); expect(computedIssueQuality.status).toBe(200); diff --git a/test/unit/mcp-upstream.test.ts b/test/unit/mcp-upstream.test.ts index 030caa0baf..f194da05d1 100644 --- a/test/unit/mcp-upstream.test.ts +++ b/test/unit/mcp-upstream.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { authenticatePrivateToken, createSessionForGitHubUser } from "../../src/auth/security"; -import { persistUpstreamRulesetSnapshot, upsertUpstreamDriftReport } from "../../src/db/repositories"; +import { persistSignalSnapshot, persistUpstreamRulesetSnapshot, upsertRepositoryFromGitHub, upsertUpstreamDriftReport } from "../../src/db/repositories"; import { GittensoryMcp } from "../../src/mcp/server"; import type { UpstreamDriftReportRecord, UpstreamRulesetSnapshotRecord } from "../../src/types"; import { createTestEnv } from "../helpers/d1"; @@ -16,6 +16,33 @@ describe("MCP contributor access", () => { /Forbidden: session can only access the authenticated GitHub login/, ); }); + + it("blocks session actors from issue-quality reports for inaccessible repos", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "private-repo", full_name: "victim/private-repo", private: true, owner: { login: "victim" }, default_branch: "main" }); + await persistSignalSnapshot(env, { + id: "private-issue-quality", + signalType: "issue-quality", + targetKey: "victim/private-repo", + repoFullName: "victim/private-repo", + payload: { + repoFullName: "victim/private-repo", + generatedAt: "2026-05-25T00:00:00.000Z", + lane: { lane: "issue_discovery" }, + issues: [{ number: 1, title: "SECRET private issue", status: "ready", score: 90, reasons: [], warnings: [] }], + summary: "fixture", + }, + generatedAt: "2026-05-25T00:00:00.000Z", + }); + const { token } = await createSessionForGitHubUser(env, { login: "attacker", id: 7 }); + const identity = await authenticatePrivateToken(env, token); + if (!identity || identity.kind !== "session") throw new Error("expected session identity"); + + const payload = await (new GittensoryMcp(env, identity) as unknown as { getIssueQuality(input: { owner: string; repo: string }): Promise<{ data: Record }> }).getIssueQuality({ owner: "victim", repo: "private-repo" }); + + expect(payload.data).toEqual({ status: "forbidden", repoFullName: "victim/private-repo" }); + expect(JSON.stringify(payload)).not.toContain("SECRET private issue"); + }); }); describe("MCP upstream drift tool", () => {