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
32 changes: 30 additions & 2 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1666,6 +1666,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<void> {
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.
Expand Down Expand Up @@ -2228,7 +2256,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<z.ZodObject<typeof listPendingActionsShape>>): Promise<ToolPayload> {
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 {
Expand All @@ -2255,7 +2283,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<z.ZodObject<typeof decidePendingActionShape>>): Promise<ToolPayload> {
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) {
Expand Down
59 changes: 58 additions & 1 deletion test/unit/mcp-automation-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, recordAuditEvent, upsertInstallation, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories";
import { createPendingAgentActionIfAbsent, getPendingAgentAction, listPendingAgentActions, recordAuditEvent, upsertInstallation, upsertOfficialMinerDetection, upsertPullRequestFromGitHub, upsertRepositoryFromGitHub, upsertRepositorySettings } from "../../src/db/repositories";
import type { AuthIdentity } from "../../src/auth/security";
import { createTestEnv } from "../helpers/d1";

Expand Down Expand Up @@ -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)", () => {
Expand Down Expand Up @@ -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);
Expand Down
Loading