From ab7fb5cb860ed14864619f2e43cdb5d703d88145 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Sat, 20 Jun 2026 09:32:37 -0700 Subject: [PATCH] fix(mcp): gate approval queue by repo maintainer scope --- src/mcp/server.ts | 32 +++++++++++++- test/unit/mcp-automation-state.test.ts | 59 +++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index a834cc1362..ca354dba7d 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -1626,6 +1626,34 @@ export class GittensoryMcp { throw new Error("Forbidden: write access is required to propose an action on this repository."); } + // Approval-queue list/decide mirrors the HTTP requireRepoWriteAccess gate: + // first require repo-scoped Gittensory maintainer/owner/operator authority, then verify live GitHub write. + private async requireRepoApprovalQueueAccess(repoFullName: string): Promise { + if (this.identity.kind !== "session") return; + const scope = await this.loadSessionAccessScope(); + if (scope.operator) return; + + const repo = await getRepository(this.env, repoFullName); + const requestedRepo = repoFullName.toLowerCase(); + const repoScoped = scope.repositoryFullNames.some((name) => name.toLowerCase() === requestedRepo); + const accountScoped = Boolean(repo && scope.accountLogins.some((login) => login.toLowerCase() === repo.owner.toLowerCase())); + if (!repoScoped && !accountScoped) { + throw new Error("Forbidden: maintainer access is required for this repository."); + } + + const installationId = repo?.installationId ?? null; + let permission: string | null = null; + if (installationId !== null) { + try { + permission = await getRepositoryCollaboratorPermission(this.env, installationId, repoFullName, this.identity.actor); + } catch { + permission = null; + } + } + if (permission && REPO_WRITE_PERMISSIONS.has(permission)) return; + throw new Error("Forbidden: write access is required to manage this repository's approval queue."); + } + // Issue-watch gate (#699 path B). Sessions may only watch repos they can SEE: any gittensory-tracked PUBLIC // repo (the miner use case) or a PRIVATE repo they can access — never an arbitrary/private repo they cannot, // so private-repo issues never fan out to them. Non-session (private-token) identities are trusted. @@ -2188,7 +2216,7 @@ export class GittensoryMcp { // (the full queue with reasons is more sensitive than the bare count in get_automation_state). private async listPendingActions(input: z.infer>): Promise { const fullName = `${input.owner}/${input.repo}`; - await this.requireRepoManageAccess(fullName); + await this.requireRepoApprovalQueueAccess(fullName); const status = input.status ?? "pending"; const actions = await listPendingAgentActions(this.env, { repoFullName: fullName, status }); return { @@ -2215,7 +2243,7 @@ export class GittensoryMcp { // access, repo-scoped (a guessed id from another repo's queue cannot be decided), idempotent. private async decidePendingAction(input: z.infer>): Promise { const fullName = `${input.owner}/${input.repo}`; - await this.requireRepoManageAccess(fullName); + await this.requireRepoApprovalQueueAccess(fullName); const pending = await getPendingAgentAction(this.env, input.id); // Scope to THIS repo so a maintainer cannot decide another repo's queue via a guessed id. if (!pending || pending.repoFullName !== fullName) { diff --git a/test/unit/mcp-automation-state.test.ts b/test/unit/mcp-automation-state.test.ts index 646d8a62c1..3b6458cf5e 100644 --- a/test/unit/mcp-automation-state.test.ts +++ b/test/unit/mcp-automation-state.test.ts @@ -3,7 +3,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { GittensoryMcp } from "../../src/mcp/server"; import { getRepositoryCollaboratorPermission } from "../../src/github/app"; -import { createPendingAgentActionIfAbsent, getPendingAgentAction, listPendingAgentActions, upsertInstallation, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; +import { createPendingAgentActionIfAbsent, getPendingAgentAction, listPendingAgentActions, upsertInstallation, upsertOfficialMinerDetection, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories"; import type { AuthIdentity } from "../../src/auth/security"; import { createTestEnv } from "../helpers/d1"; @@ -207,12 +207,68 @@ describe("MCP gittensory_list_pending_actions (#784)", () => { it("forbids a session without live write access", async () => { const env = createTestEnv(); await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + await upsertPullRequestFromGitHub(env, "owner/repo", { number: 1, title: "x", state: "open", user: { login: "rando" }, author_association: "OWNER", head: { sha: "sha" } }); mockedPermission.mockResolvedValue("read"); const client = await connect(env, { kind: "session", actor: "rando" } as AuthIdentity); const result = await client.callTool({ name: "gittensory_list_pending_actions", arguments: { owner: "owner", repo: "repo" } }); expect(result.isError).toBe(true); expect(JSON.stringify(result)).toMatch(/write access/i); }); + + it("forbids a miner-only session even when live GitHub write access exists", async () => { + const env = createTestEnv(); + await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + await upsertOfficialMinerDetection( + env, + "miner", + { + status: "confirmed", + snapshot: { + source: "gittensor_api", + githubId: "123", + githubUsername: "miner", + uid: 7, + hotkey: "hotkey", + failedReason: null, + evaluatedAt: "2026-06-20T00:00:00.000Z", + updatedAt: "2026-06-20T00:00:00.000Z", + isEligible: true, + credibility: 1, + eligibleRepoCount: 1, + issueDiscoveryScore: 0, + issueTokenScore: 0, + issueCredibility: 0, + isIssueEligible: false, + issueEligibleRepoCount: 0, + alphaPerDay: 0, + taoPerDay: 0, + usdPerDay: 0, + totals: { + pullRequests: 0, + mergedPullRequests: 0, + openPullRequests: 0, + closedPullRequests: 0, + openIssues: 0, + closedIssues: 0, + solvedIssues: 0, + validSolvedIssues: 0, + }, + repositories: [], + pullRequests: [], + issueLabels: [], + }, + }, + 60_000, + ); + await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber: 7, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: {}, reason: "sensitive" }); + mockedPermission.mockResolvedValue("write"); + + const client = await connect(env, { kind: "session", actor: "miner" } as AuthIdentity); + const result = await client.callTool({ name: "gittensory_list_pending_actions", arguments: { owner: "owner", repo: "repo" } }); + + expect(result.isError).toBe(true); + expect(JSON.stringify(result)).toMatch(/maintainer access/i); + }); }); describe("MCP gittensory_decide_pending_action (#784)", () => { @@ -265,6 +321,7 @@ describe("MCP gittensory_decide_pending_action (#784)", () => { it("forbids a session without live write access and leaves the action pending", async () => { const env = createTestEnv(); await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5); + await upsertPullRequestFromGitHub(env, "owner/repo", { number: 1, title: "x", state: "open", user: { login: "rando" }, author_association: "OWNER", head: { sha: "sha" } }); const { action } = await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber: 7, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: {}, reason: "x" }); mockedPermission.mockResolvedValue("read"); const client = await connect(env, { kind: "session", actor: "rando" } as AuthIdentity);