From d01267ebb67f2225429e65fe034b0873bf43ed2a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:04:51 -0700 Subject: [PATCH 1/2] fix: enforce hosted entry visibility when minting grants Co-authored-by: rivet Agent-Model: codex/gpt-6.1-sol/medium --- .../__tests__/unit/routes/grants.mint.test.js | 45 +++++++++++++++++++ backend/routes/grants.ts | 7 +++ .../runbooks/hosted-mcp-hidden-entry-walks.md | 12 ++--- 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/backend/__tests__/unit/routes/grants.mint.test.js b/backend/__tests__/unit/routes/grants.mint.test.js index daca837174..722e72df58 100644 --- a/backend/__tests__/unit/routes/grants.mint.test.js +++ b/backend/__tests__/unit/routes/grants.mint.test.js @@ -239,6 +239,51 @@ const withCatalog = async (entries, run) => { }; describe('POST /api/grants on a hosted-mcp connection', () => { + describe('hidden-entry mint policy', () => { + let previousHidden; + let previousAllowlist; + beforeEach(() => { + previousHidden = process.env.HOSTED_MCP_HIDDEN_ENTRIES; + previousAllowlist = process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST; + process.env.HOSTED_MCP_HIDDEN_ENTRIES = JSON.stringify(['linear']); + delete process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST; + Integration.findById.mockResolvedValue(hostedRow()); + }); + afterEach(() => { + if (previousHidden === undefined) delete process.env.HOSTED_MCP_HIDDEN_ENTRIES; + else process.env.HOSTED_MCP_HIDDEN_ENTRIES = previousHidden; + if (previousAllowlist === undefined) delete process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST; + else process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST = previousAllowlist; + }); + + test.each([ + ['no exception', undefined], + ['another user', JSON.stringify({ linear: [SEAT] })], + ['invalid JSON', '{broken'], + ['invalid user in map', JSON.stringify({ linear: [OWNER, 'invalid'] })], + ['unknown key in map', JSON.stringify({ linear: [OWNER], typo: [OWNER] })], + ])('a previously connected hidden row refuses mint with %s', async (_, allowlist) => { + if (allowlist !== undefined) process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST = allowlist; + const res = await withCatalog([ENTRY], () => hostedMint({ userId: SEAT, ownerId: SEAT })); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'unknown_entry' }); + expect(await RoomGrant.countDocuments({})).toBe(0); + }); + + test('the exact authenticated allowlisted owner may mint', async () => { + process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST = JSON.stringify({ linear: [OWNER] }); + const res = await withCatalog([ENTRY], () => hostedMint()); + expect(res.status).toBe(201); + expect(await RoomGrant.countDocuments({ grantId: res.body.grantId })).toBe(1); + }); + + test('hiding another entry leaves this connected row grantable', async () => { + process.env.HOSTED_MCP_HIDDEN_ENTRIES = JSON.stringify(['acme']); + const res = await withCatalog([ENTRY], () => hostedMint()); + expect(res.status).toBe(201); + }); + }); + test('a connected hosted row is admitted; what stops the mint is the catalogue, not its type', async () => { Integration.findById.mockResolvedValue(hostedRow()); // The catalogue is emptied for this arm on purpose: since step 7 the shipped diff --git a/backend/routes/grants.ts b/backend/routes/grants.ts index cb06f4a97a..5109ac16d3 100644 --- a/backend/routes/grants.ts +++ b/backend/routes/grants.ts @@ -4,6 +4,7 @@ import { cloudflareIpRateLimitKeyGenerator } from '../middleware/ipRateLimit'; import { createHash } from 'crypto'; import { Types } from 'mongoose'; import Integration from '../models/Integration'; +import { isHostedMcpEntryHidden } from '../integrations/hostedMcp/visibility'; import Pod from '../models/Pod'; import RoomGrant from '../models/RoomGrant'; import ToolCall from '../models/ToolCall'; @@ -318,6 +319,12 @@ router.post('/', grantRateLimit, auth, async (req: AuthenticatedRequest, res: ex : String(connection.installationId || connection.config?.installationId || '').trim(); const isConnected = connection.status === 'connected' && !connection.revokedAt; if (hosted) { + // Existing connections do not bypass instance visibility policy. Use the + // authenticated owner, matching the catalogue and connection-start route. + const entryId = String(connection.config?.entryId || '').trim(); + if (isHostedMcpEntryHidden(entryId, process.env, userId)) { + return res.status(404).json({ error: 'unknown_entry' }); + } // A pending row holds nothing secret and must never be grantable, and a // row with no credential has nothing for the broker to decrypt, so both // are refused here rather than at first call. diff --git a/docs/runbooks/hosted-mcp-hidden-entry-walks.md b/docs/runbooks/hosted-mcp-hidden-entry-walks.md index 6f55874eaf..b9a9a1644f 100644 --- a/docs/runbooks/hosted-mcp-hidden-entry-walks.md +++ b/docs/runbooks/hosted-mcp-hidden-entry-walks.md @@ -1,7 +1,8 @@ # Walk a hidden hosted MCP entry Instance policy `hostedMcp.hiddenEntries` hides entries from the Tools catalogue -and refuses new connect/reconnect starts. To offer one hidden entry to a named +and refuses new connect/reconnect starts and new root grants for existing +connections. To offer one hidden entry to a named walk user, configure an exception alongside the hidden list: ```yaml @@ -18,15 +19,16 @@ an empty map. A deployment is required to change the environment. The map is entry ID to an array of exact, lowercase, 24-character Mongo User IDs. Names, email addresses, roles, query parameters and request body fields do not -grant an exception. Catalogue reads and authenticated connect starts use the -server-authenticated user's ID. A missing user or malformed map grants no +grant an exception. Catalogue reads, authenticated connect starts and root-grant +mints use the server-authenticated user's ID. A missing user or malformed map grants no exceptions; one invalid map member invalidates all exceptions. An exception key must also name an entry in the configured hidden list; a stale or mistyped key invalidates the entire exception map. Other users -continue to see no offering and receive `404 unknown_entry` on start. +continue to see no offering and receive `404 unknown_entry` on start or root-grant +mint, including when they own a connected row created before the entry was hidden. An exception does not configure a connector client, grant access to another -person's connections, or change tool grants. An unconfigured client remains +person's connections, or revoke existing tool grants. An unconfigured client remains unavailable. Existing hidden connections retain their removal surface and pinned broker tools as before. Public client metadata and callbacks are not new-offering checks: the former is fetched by authorization servers, and the latter already From 63980066523533d1eb1e5abc31703da02106a00e Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:44:42 -0700 Subject: [PATCH 2/2] fix: refuse hosted grant mints without an entry id Co-authored-by: rivet Agent-Model: codex/gpt-6.1-sol/medium --- backend/__tests__/unit/routes/grants.mint.test.js | 8 ++++++++ backend/routes/grants.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/__tests__/unit/routes/grants.mint.test.js b/backend/__tests__/unit/routes/grants.mint.test.js index 722e72df58..50007d462e 100644 --- a/backend/__tests__/unit/routes/grants.mint.test.js +++ b/backend/__tests__/unit/routes/grants.mint.test.js @@ -270,6 +270,14 @@ describe('POST /api/grants on a hosted-mcp connection', () => { expect(await RoomGrant.countDocuments({})).toBe(0); }); + test.each([undefined, '', ' '])('a hosted row without an entry ID refuses before mint: %p', async (entryId) => { + Integration.findById.mockResolvedValue(hostedRow({ entryId })); + const res = await withCatalog([ENTRY], () => hostedMint()); + expect(res.status).toBe(404); + expect(res.body).toEqual({ error: 'unknown_entry' }); + expect(await RoomGrant.countDocuments({})).toBe(0); + }); + test('the exact authenticated allowlisted owner may mint', async () => { process.env.HOSTED_MCP_HIDDEN_ENTRY_USER_ALLOWLIST = JSON.stringify({ linear: [OWNER] }); const res = await withCatalog([ENTRY], () => hostedMint()); diff --git a/backend/routes/grants.ts b/backend/routes/grants.ts index 5109ac16d3..a182f08a2e 100644 --- a/backend/routes/grants.ts +++ b/backend/routes/grants.ts @@ -322,7 +322,7 @@ router.post('/', grantRateLimit, auth, async (req: AuthenticatedRequest, res: ex // Existing connections do not bypass instance visibility policy. Use the // authenticated owner, matching the catalogue and connection-start route. const entryId = String(connection.config?.entryId || '').trim(); - if (isHostedMcpEntryHidden(entryId, process.env, userId)) { + if (!entryId || isHostedMcpEntryHidden(entryId, process.env, userId)) { return res.status(404).json({ error: 'unknown_entry' }); } // A pending row holds nothing secret and must never be grantable, and a