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
13 changes: 13 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -3487,6 +3495,7 @@ function isExtensionScopedSession(identity: AuthIdentity): boolean {
function canSessionAccessPath(env: Env, identity: Extract<AuthIdentity, { kind: "session" }>, 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;
Expand All @@ -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<AuthIdentity | null> {
const bearer = await authenticatePrivateToken(c.env, extractBearerToken(c.req.header("authorization")));
if (bearer) return bearer;
Expand Down
18 changes: 17 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -862,6 +862,12 @@ export class GittensoryMcp {

private async getIssueQuality(input: { owner: string; repo: string }): Promise<ToolPayload> {
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 {
Expand All @@ -878,6 +884,16 @@ export class GittensoryMcp {
};
}

private async canAccessRepo(fullName: string): Promise<boolean> {
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<ToolPayload> {
const fullName = `${input.owner}/${input.repo}`;
const response = await loadOrComputeRepoOutcomePatternsResponse(this.env, fullName);
Expand Down
5 changes: 5 additions & 0 deletions test/integration/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
29 changes: 28 additions & 1 deletion test/unit/mcp-upstream.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<string, unknown> }> }).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", () => {
Expand Down