diff --git a/apps/gittensory-ui/src/routes/docs.self-hosting-configuration.tsx b/apps/gittensory-ui/src/routes/docs.self-hosting-configuration.tsx
index 7f95337977..a2339e3486 100644
--- a/apps/gittensory-ui/src/routes/docs.self-hosting-configuration.tsx
+++ b/apps/gittensory-ui/src/routes/docs.self-hosting-configuration.tsx
@@ -76,6 +76,24 @@ INTERNAL_JOB_TOKEN=`}
Any FOO_FILE is loaded into FOO at startup. Explicit{" "}
FOO wins over the file variant.
+
+ GITTENSORY_MCP_TOKEN is a shared, end-user-obtainable CLI credential (the
+ normal alternative to gittensory-mcp login), so it must not implicitly stage
+ actions (merges, closes, approvals) on every repo the App happens to be installed on.{" "}
+ MCP_ACTUATION_REPO_ALLOWLIST scopes it to an explicit,
+ comma/whitespace-separated owner/repo list —{" "}
+ unset denies all actuation for this token. Set it to * or{" "}
+ all to opt back into the pre-scoping, any-repo behavior. If you already rely on{" "}
+ GITTENSORY_MCP_TOKEN for approval-queue actuation, set this variable after
+ upgrading or MCP actuation stops working.
+
+
GitHub API cache
diff --git a/src/auth/security.ts b/src/auth/security.ts
index 3ee0f3a0a3..8f2b2d14fb 100644
--- a/src/auth/security.ts
+++ b/src/auth/security.ts
@@ -141,6 +141,21 @@ export function parseGitHubLoginList(value: string | undefined): Set {
);
}
+/** Is `repoFullName` within the operator's MCP_ACTUATION_REPO_ALLOWLIST? The static `mcp` identity is minted from
+ * a single shared secret (GITTENSORY_MCP_TOKEN) that is documented as an ordinary end-user CLI credential — unlike
+ * `api`/`internal`, it is not operator-only, so unlike those it must NOT be unconditionally trusted for every
+ * installed repo. Unset/empty ⇒ deny (fail closed: an operator must explicitly opt a repo in). `*`/`all` ⇒ every
+ * repo, an explicit escape hatch for an operator who wants the old unscoped-trust behavior. (#2253) */
+export function isMcpActuationRepoAllowed(value: string | undefined, repoFullName: string): boolean {
+ const entries = (value ?? "")
+ .split(/[\s,]+/)
+ .map((entry) => entry.trim().toLowerCase())
+ .filter(Boolean);
+ if (entries.length === 0) return false;
+ if (entries.includes("*") || entries.includes("all")) return true;
+ return entries.includes(repoFullName.toLowerCase());
+}
+
type CookieOptions = {
maxAge: number;
path: string;
diff --git a/src/env.d.ts b/src/env.d.ts
index fd2d8b551a..83409d6f9c 100644
--- a/src/env.d.ts
+++ b/src/env.d.ts
@@ -134,6 +134,10 @@ declare global {
GITTENSORY_API_TOKEN: string;
GITTENSORY_MCP_TOKEN: string;
INTERNAL_JOB_TOKEN: string;
+ /** Repos the shared GITTENSORY_MCP_TOKEN may propose/decide/manage actions on (comma/whitespace `owner/repo`
+ * list, or `*`/`all` for every repo). Unset ⇒ none — GITTENSORY_MCP_TOKEN is a shared, end-user-obtainable
+ * credential, so it must not implicitly actuate on every installed repo (#2253). */
+ MCP_ACTUATION_REPO_ALLOWLIST?: string;
/** Shared bearer secret required by the hosted Orb ingest collector. */
ORB_INGEST_TOKEN?: string;
/** AES-256-GCM master secret for maintainer BYOK provider keys (encrypt/decrypt at rest). A Worker/self-host
diff --git a/src/mcp/server.ts b/src/mcp/server.ts
index 48c65224b2..eda5c352ca 100644
--- a/src/mcp/server.ts
+++ b/src/mcp/server.ts
@@ -4,7 +4,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
import { ElicitResultSchema, type ServerNotification, type ServerRequest } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
-import { authenticatePrivateToken, extractBearerToken, type AuthIdentity } from "../auth/security";
+import { authenticatePrivateToken, extractBearerToken, isMcpActuationRepoAllowed, type AuthIdentity } from "../auth/security";
import { canLoginAccessRepo, canWatchRepo, loadControlPanelAccessScope, loadControlPanelRoleSummary, type ControlPanelAccessScope } from "../services/control-panel-roles";
import {
countOpenIssues,
@@ -1778,8 +1778,15 @@ export class GittensoryMcp {
}
// Stricter than requireRepoAccess (read): a maintainer-MANAGE gate for write actions (#784 propose-action).
- // A session must own/maintain the repo (or be an operator); private-token / static identities are trusted.
+ // A session must own/maintain the repo (or be an operator); api/internal static identities are trusted (they
+ // are operator-only Worker secrets, never handed to end users). The static `mcp` identity is NOT trusted here:
+ // GITTENSORY_MCP_TOKEN is a shared, end-user-obtainable CLI credential, so it is scoped to an explicit
+ // operator-configured allowlist instead (#2253).
private async requireRepoManageAccess(repoFullName: string): Promise {
+ if (this.identity.kind === "static" && this.identity.actor === "mcp") {
+ if (isMcpActuationRepoAllowed(this.env.MCP_ACTUATION_REPO_ALLOWLIST, repoFullName)) return;
+ throw new Error("Forbidden: this repository is not in the operator's MCP_ACTUATION_REPO_ALLOWLIST.");
+ }
if (this.identity.kind !== "session") return;
const scope = await this.loadSessionAccessScope();
if (scope.operator) return;
@@ -1800,7 +1807,13 @@ export class GittensoryMcp {
// Approval-queue list/decide mirrors the HTTP requireRepoWriteAccess gate:
// first require repo-scoped Gittensory maintainer/owner/operator authority, then verify live GitHub write.
+ // See requireRepoManageAccess above: api/internal static identities are trusted; the static `mcp` identity is
+ // scoped to MCP_ACTUATION_REPO_ALLOWLIST instead, since GITTENSORY_MCP_TOKEN is a shared end-user credential (#2253).
private async requireRepoApprovalQueueAccess(repoFullName: string): Promise {
+ if (this.identity.kind === "static" && this.identity.actor === "mcp") {
+ if (isMcpActuationRepoAllowed(this.env.MCP_ACTUATION_REPO_ALLOWLIST, repoFullName)) return;
+ throw new Error("Forbidden: this repository is not in the operator's MCP_ACTUATION_REPO_ALLOWLIST.");
+ }
if (this.identity.kind !== "session") return;
const scope = await this.loadSessionAccessScope();
if (scope.operator) return;
diff --git a/test/helpers/d1.ts b/test/helpers/d1.ts
index 40ccdee9a5..324244c8c4 100644
--- a/test/helpers/d1.ts
+++ b/test/helpers/d1.ts
@@ -78,6 +78,7 @@ export function createTestEnv(overrides: Partial = {}): Env {
GITHUB_WEBHOOK_SECRET: "test-webhook-secret",
GITHUB_APP_PRIVATE_KEY: "test-private-key",
ADMIN_GITHUB_LOGINS: "jsonbored",
+ MCP_ACTUATION_REPO_ALLOWLIST: "*",
SELFHOST_TRANSIENT_CACHE: {
async get(key: string) {
return transientCache.get(key) ?? null;
diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts
index fa1f946190..6df556093d 100644
--- a/test/unit/auth.test.ts
+++ b/test/unit/auth.test.ts
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { completeGitHubWebOAuth, createSessionFromGitHubToken, pollGitHubDeviceFlow, startGitHubDeviceFlow, startGitHubWebOAuth } from "../../src/auth/github-oauth";
import { enforceRateLimit, RateLimiter, routeClassForPath } from "../../src/auth/rate-limit";
-import { authenticatePrivateToken, buildBrowserSessionCookie, createSessionForGitHubUser, extractCookieValue, isAuthorizedGitHubSessionLogin, revokeSession, timingSafeEqual } from "../../src/auth/security";
+import { authenticatePrivateToken, buildBrowserSessionCookie, createSessionForGitHubUser, extractCookieValue, isAuthorizedGitHubSessionLogin, isMcpActuationRepoAllowed, revokeSession, timingSafeEqual } from "../../src/auth/security";
import { createTestEnv } from "../helpers/d1";
describe("private-beta auth and rate limiting", () => {
@@ -33,6 +33,23 @@ describe("private-beta auth and rate limiting", () => {
await expect(authenticatePrivateToken(env, malformed.token)).resolves.toBeNull();
});
+ it("scopes MCP static-token actuation to an explicit repo allowlist, denying by default (#2253)", () => {
+ // Unset/empty ⇒ deny (fail closed — the shared GITTENSORY_MCP_TOKEN must not implicitly actuate everywhere).
+ expect(isMcpActuationRepoAllowed(undefined, "owner/repo")).toBe(false);
+ expect(isMcpActuationRepoAllowed("", "owner/repo")).toBe(false);
+ expect(isMcpActuationRepoAllowed(" ", "owner/repo")).toBe(false);
+ // An explicitly listed repo is allowed; a sibling repo NOT listed stays denied.
+ expect(isMcpActuationRepoAllowed("owner/repo", "owner/repo")).toBe(true);
+ expect(isMcpActuationRepoAllowed("owner/repo", "owner/other")).toBe(false);
+ // Case-insensitive, and accepts whitespace OR comma-separated lists (matches parseGitHubLoginList's parse).
+ expect(isMcpActuationRepoAllowed("Owner/Repo", "owner/repo")).toBe(true);
+ expect(isMcpActuationRepoAllowed("owner/one,owner/two", "owner/two")).toBe(true);
+ expect(isMcpActuationRepoAllowed("owner/one owner/two", "owner/two")).toBe(true);
+ // `*`/`all` is an explicit operator opt-in to the old unscoped-trust behavior — never the unset default.
+ expect(isMcpActuationRepoAllowed("*", "owner/anything")).toBe(true);
+ expect(isMcpActuationRepoAllowed("all", "owner/anything")).toBe(true);
+ });
+
it("handles auth helper fallbacks for cookies, login lists, and token comparison", async () => {
await expect(timingSafeEqual(undefined, "expected")).resolves.toBe(false);
await expect(timingSafeEqual("short", "shorter")).resolves.toBe(false);
diff --git a/test/unit/mcp-automation-state.test.ts b/test/unit/mcp-automation-state.test.ts
index b2f76c9c5a..553f8bb737 100644
--- a/test/unit/mcp-automation-state.test.ts
+++ b/test/unit/mcp-automation-state.test.ts
@@ -189,6 +189,47 @@ describe("MCP gittensory_propose_action (#784)", () => {
expect(await listPendingAgentActions(env, { repoFullName: "owner/repo" })).toHaveLength(0);
});
+ it("denies a static MCP-token caller when the repo is not in MCP_ACTUATION_REPO_ALLOWLIST (#2253)", async () => {
+ // GITTENSORY_MCP_TOKEN is a shared, end-user-obtainable CLI credential — unlike an explicit maintainer
+ // session, it must not implicitly stage actions on every repo the App happens to be installed on.
+ // createTestEnv's own default is MCP_ACTUATION_REPO_ALLOWLIST: "*" (so unrelated tests aren't broken
+ // by this restriction); "" overrides that back to unset (isMcpActuationRepoAllowed treats "" the same
+ // as undefined) to exercise the real deny-by-default behavior.
+ const env = createTestEnv({ MCP_ACTUATION_REPO_ALLOWLIST: "" });
+ await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5);
+ const client = await connect(env); // default identity: { kind: "static", actor: "mcp" }
+ const result = await client.callTool({ name: "gittensory_propose_action", arguments: { owner: "owner", repo: "repo", pullNumber: 7, actionClass: "merge" } });
+ expect(result.isError).toBe(true);
+ expect(JSON.stringify(result)).toMatch(/MCP_ACTUATION_REPO_ALLOWLIST/);
+ expect(await listPendingAgentActions(env, { repoFullName: "owner/repo" })).toHaveLength(0);
+ });
+
+ it("allows a static MCP-token caller once the repo is explicitly allowlisted, but not a sibling repo (#2253)", async () => {
+ const env = createTestEnv({ MCP_ACTUATION_REPO_ALLOWLIST: "owner/repo" });
+ await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5);
+ await upsertRepositoryFromGitHub(env, { name: "other", full_name: "owner/other", private: false, owner: { login: "owner" } }, 5);
+ const client = await connect(env);
+
+ const allowed = await client.callTool({ name: "gittensory_propose_action", arguments: { owner: "owner", repo: "repo", pullNumber: 7, actionClass: "merge" } });
+ expect(allowed.isError).toBeFalsy();
+
+ const denied = await client.callTool({ name: "gittensory_propose_action", arguments: { owner: "owner", repo: "other", pullNumber: 7, actionClass: "merge" } });
+ expect(denied.isError).toBe(true);
+ expect(await listPendingAgentActions(env, { repoFullName: "owner/other" })).toHaveLength(0);
+ });
+
+ it("leaves the api/internal static identities unconditionally trusted (unaffected by the mcp allowlist) (#2253)", async () => {
+ // api/internal are operator-only Worker secrets, never handed to end users — unlike the mcp actor, they are
+ // NOT scoped to MCP_ACTUATION_REPO_ALLOWLIST. Confirmed here with the allowlist unset, so this only passes
+ // because api/internal skip that check entirely (not because the repo happens to be allowlisted).
+ // MCP_ACTUATION_REPO_ALLOWLIST is irrelevant here: api/internal skip that check entirely (see below).
+ const env = createTestEnv({});
+ await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5);
+ const client = await connect(env, { kind: "static", actor: "api" } as AuthIdentity);
+ const result = await client.callTool({ name: "gittensory_propose_action", arguments: { owner: "owner", repo: "repo", pullNumber: 7, actionClass: "merge" } });
+ expect(result.isError).toBeFalsy();
+ });
+
it("does not trust cached collaborator association without live write permission", async () => {
const env = createTestEnv();
await upsertInstallation(env, {
@@ -338,6 +379,29 @@ describe("MCP gittensory_decide_pending_action (#784)", () => {
expect((await getPendingAgentAction(env, action.id))?.status).toBe("accepted");
});
+ it("denies a static MCP-token caller from deciding a pending action when the repo is not allowlisted (#2253)", async () => {
+ // "" overrides createTestEnv's own MCP_ACTUATION_REPO_ALLOWLIST: "*" default back to unset.
+ const env = createTestEnv({ MCP_ACTUATION_REPO_ALLOWLIST: "" });
+ await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5);
+ const { action } = await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber: 7, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: {}, reason: "x" });
+
+ const client = await connect(env);
+ const result = await client.callTool({ name: "gittensory_decide_pending_action", arguments: { owner: "owner", repo: "repo", id: action.id, decision: "accept" } });
+ expect(result.isError).toBe(true);
+ expect(JSON.stringify(result)).toMatch(/MCP_ACTUATION_REPO_ALLOWLIST/);
+ expect((await getPendingAgentAction(env, action.id))?.status).toBe("pending"); // left untouched, not silently accepted
+ });
+
+ it("leaves the api/internal static identities unconditionally trusted for the approval queue too (#2253)", async () => {
+ // MCP_ACTUATION_REPO_ALLOWLIST is irrelevant here: api/internal skip that check entirely (see below).
+ const env = createTestEnv({});
+ await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5);
+ const { action } = await createPendingAgentActionIfAbsent(env, { repoFullName: "owner/repo", pullNumber: 7, installationId: 5, actionClass: "merge", autonomyLevel: "auto_with_approval", params: {}, reason: "x" });
+ const client = await connect(env, { kind: "static", actor: "internal" } as AuthIdentity);
+ const result = await client.callTool({ name: "gittensory_decide_pending_action", arguments: { owner: "owner", repo: "repo", id: action.id, decision: "reject" } });
+ expect(result.isError).toBeFalsy();
+ });
+
it("is repo-scoped: a guessed id from another repo's queue is not_found and left untouched", async () => {
const env = createTestEnv();
await upsertRepositoryFromGitHub(env, { name: "repo", full_name: "owner/repo", private: false, owner: { login: "owner" } }, 5);