diff --git a/server/src/__tests__/tool-gateway.test.ts b/server/src/__tests__/tool-gateway.test.ts index 55f1cd7f173..b0168f5f932 100644 --- a/server/src/__tests__/tool-gateway.test.ts +++ b/server/src/__tests__/tool-gateway.test.ts @@ -12,6 +12,7 @@ import { companySecretVersions, companyMemberships, companies, + connectionGrantMembers, connectionGrants, createDb, heartbeatRuns, @@ -285,6 +286,11 @@ async function createLocalStdioMcpTool( healthStatus?: "unknown" | "healthy" | "degraded" | "failed" | "unchecked" | "ok" | "error" | "missing_secret"; catalogStatus?: "active" | "disabled" | "quarantined" | "removed"; riskLevel?: "read" | "write" | "destructive"; + credentialPolicy?: "shared" | "per_user" | "per_user_with_fallback"; + credentialSecretRefs?: typeof toolConnections.$inferInsert["credentialSecretRefs"]; + stdioScript?: string; + envKeys?: string[]; + connectionConfig?: Record; } = {}, ) { const applicationKey = input.applicationKey ?? `local-app-${randomUUID().slice(0, 8)}`; @@ -342,9 +348,31 @@ rl.on("line", (line) => { status: input.connectionStatus ?? "active", enabled: input.connectionEnabled ?? true, healthStatus: input.healthStatus ?? "ok", + credentialPolicy: input.credentialPolicy ?? "shared", config: { templateId: templateKey, ...(input.connectionConfig ?? {}) }, transportConfig: { templateId: templateKey, ...(input.connectionConfig ?? {}) }, + credentialSecretRefs: input.credentialSecretRefs ?? [], }).returning(); + await db.insert(connectionGrants).values({ + companyId, + connectionId: connection.id, + kind: "organization", + credentialSecretRefs: connection.credentialSecretRefs, + status: "active", + isDefault: true, + }); + if (input.credentialSecretRefs?.length) { + await db.insert(companySecretBindings).values(input.credentialSecretRefs.map((ref) => ({ + companyId, + secretId: ref.secretId, + targetType: "tool_connection" as const, + targetId: connection.id, + configPath: ref.configPath, + versionSelector: String(ref.versionSelector ?? "latest"), + required: ref.required ?? true, + label: ref.label ?? null, + }))).onConflictDoNothing(); + } const [catalogEntry] = await db.insert(toolCatalogEntries).values({ companyId, applicationId: application!.id, @@ -1285,13 +1313,26 @@ describeEmbeddedPostgres("tool gateway acceptance", () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); const { run } = await createIssueAndRun(db, company.id, agent.id); + const allowedToken = await secretService(db).create(company.id, { + name: `Local stdio token ${randomUUID()}`, + key: `local_stdio_token_${randomUUID().replace(/-/g, "")}`, + provider: "local_encrypted", + value: "allowed-token", + }); const localTool = await createLocalStdioMcpTool(db, company.id, { applicationKey: "local-env-demo", connectionName: "Local Env Demo", toolName: "inspect_env", title: "Inspect env", envKeys: ["ALLOWED_TOKEN"], - connectionConfig: { env: { ALLOWED_TOKEN: "allowed-token", EXTRA_CONFIG: "extra-value", NODE_OPTIONS: "--trace-warnings" } }, + credentialSecretRefs: [{ + secretId: allowedToken.id, + versionSelector: "latest", + configPath: "env.ALLOWED_TOKEN", + required: true, + label: "Allowed token", + }], + connectionConfig: { env: { ALLOWED_TOKEN: "connection-level-token", EXTRA_CONFIG: "extra-value", NODE_OPTIONS: "--trace-warnings" } }, stdioScript: ` const readline = require("node:readline"); const rl = readline.createInterface({ input: process.stdin }); @@ -1361,6 +1402,135 @@ rl.on("line", (line) => { } }); + it("passes only the selected grant identity to local stdio MCP processes", async () => { + const company = await createCompany(db); + const agent = await createAgent(db, company.id); + const { run } = await createIssueAndRun(db, company.id, agent.id); + await db.update(heartbeatRuns).set({ responsibleUserId: "alice" }).where(eq(heartbeatRuns.id, run.id)); + const values = { + organization: `organization-${randomUUID()}`, + alice: `alice-${randomUUID()}`, + bob: `bob-${randomUUID()}`, + }; + const organizationSecret = await secretService(db).create(company.id, { + name: `Organization stdio token ${randomUUID()}`, + key: `organization_stdio_${randomUUID().replace(/-/g, "")}`, + provider: "local_encrypted", + value: values.organization, + }); + const aliceSecret = await secretService(db).create(company.id, { + name: `Alice stdio token ${randomUUID()}`, + key: `alice_stdio_${randomUUID().replace(/-/g, "")}`, + provider: "local_encrypted", + value: values.alice, + }); + const bobSecret = await secretService(db).create(company.id, { + name: `Bob stdio token ${randomUUID()}`, + key: `bob_stdio_${randomUUID().replace(/-/g, "")}`, + provider: "local_encrypted", + value: values.bob, + }); + const localTool = await createLocalStdioMcpTool(db, company.id, { + applicationKey: "local-grant-identity", + toolName: "identity", + title: "Grant identity", + envKeys: ["IDENTITY_TOKEN"], + credentialSecretRefs: [{ + secretId: organizationSecret.id, + versionSelector: "latest", + configPath: "env.IDENTITY_TOKEN", + required: true, + label: "Organization identity", + }], + connectionConfig: { env: { IDENTITY_TOKEN: "legacy-connection-identity" } }, + stdioScript: ` +const readline = require("node:readline"); +const identities = ${JSON.stringify(values)}; +const rl = readline.createInterface({ input: process.stdin }); +rl.on("line", (line) => { + const message = JSON.parse(line); + if (message.method === "initialize") { + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: "2024-11-05", capabilities: {}, serverInfo: { name: "identity-stdio", version: "0.0.0" } } }) + "\\n"); + return; + } + if (message.method === "tools/call") { + const identity = Object.entries(identities).find(([, value]) => value === process.env.IDENTITY_TOKEN)?.[0] ?? "unknown"; + process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id: message.id, result: { content: [{ type: "text", text: identity }], structuredContent: { identity } } }) + "\\n"); + } +}); +`, + }); + const grantRef = (secretId: string, label: string) => ({ + secretId, + versionSelector: "latest" as const, + configPath: "env.IDENTITY_TOKEN", + required: true, + label, + }); + const [aliceGrant] = await db.insert(connectionGrants).values({ + companyId: company.id, + connectionId: localTool.connection.id, + kind: "user", + subjectUserId: "alice", + credentialSecretRefs: [grantRef(aliceSecret.id, "Alice identity")], + status: "active", + isDefault: false, + }).returning(); + await db.insert(connectionGrants).values({ + companyId: company.id, + connectionId: localTool.connection.id, + kind: "user", + subjectUserId: "bob", + credentialSecretRefs: [grantRef(bobSecret.id, "Bob identity")], + status: "active", + isDefault: false, + }); + await allowAllToolsForAgent(db, company.id, agent.id); + const gateway = createTestToolGatewayService(db, { runtimeSupervisor: { idleTtlMs: 10_000 } }); + const session = await gateway.createSession({ companyId: company.id, agentId: agent.id, runId: run.id }); + const tool = (await gateway.listToolsForSession(session.token)).find((item) => item.connectionId === localTool.connection.id)!; + const executeIdentity = async () => { + const result = await gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} }); + return (result.result as { data?: { structuredContent?: { identity?: string } } }).data?.structuredContent?.identity; + }; + + expect(await executeIdentity()).toBe("organization"); + await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, localTool.connection.id)); + expect(await executeIdentity()).toBe("alice"); + await db.update(toolConnections).set({ credentialPolicy: "per_user_with_fallback" }).where(eq(toolConnections.id, localTool.connection.id)); + expect(await executeIdentity()).toBe("alice"); + await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, aliceGrant.id)); + expect(await executeIdentity()).toBe("organization"); + + await db.delete(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id)); + await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, localTool.connection.id)); + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} })) + .rejects.toMatchObject({ status: 409, reasonCode: "user_authorization_required" }); + expect(await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id))).toHaveLength(0); + + const [organizationGrant] = await db.select().from(connectionGrants).where(and( + eq(connectionGrants.connectionId, localTool.connection.id), + eq(connectionGrants.kind, "organization"), + )); + await db.insert(connectionGrantMembers).values({ + companyId: company.id, + grantId: organizationGrant.id, + subjectType: "user", + subjectId: "sales-user", + }); + await db.update(toolConnections).set({ credentialPolicy: "shared" }).where(eq(toolConnections.id, localTool.connection.id)); + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} })) + .rejects.toMatchObject({ status: 403, reasonCode: "grant_audience_denied" }); + expect(await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id))).toHaveLength(0); + + await db.delete(connectionGrantMembers).where(eq(connectionGrantMembers.grantId, organizationGrant.id)); + await db.update(toolConnections).set({ credentialPolicy: "per_user" }).where(eq(toolConnections.id, localTool.connection.id)); + await db.update(heartbeatRuns).set({ responsibleUserId: null }).where(eq(heartbeatRuns.id, run.id)); + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: {} })) + .rejects.toMatchObject({ status: 409, reasonCode: "user_authorization_required" }); + expect(await db.select().from(toolRuntimeSlots).where(eq(toolRuntimeSlots.connectionId, localTool.connection.id))).toHaveLength(0); + }); + it("keeps connected remote MCP gateway names collision-safe and excludes inactive catalog sources", async () => { const company = await createCompany(db); const agent = await createAgent(db, company.id); diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 1047e18e50d..83c0a6deef5 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -2414,6 +2414,7 @@ export function createToolGatewayService( } async function resolveCredentialHeaders( + session: ToolGatewaySession, connection: typeof toolConnections.$inferSelect, grant: typeof connectionGrants.$inferSelect, ): Promise> { @@ -2424,10 +2425,15 @@ export function createToolGatewayService( if (!grantRef) continue; try { const value = await secrets.resolveSecretValue(connection.companyId, grantRef.secretId, grantRef.versionSelector ?? "latest", { - consumerType: "tool_connection", - consumerId: connection.id, - configPath: `credentials.${ref.name}`, - actorType: "system", + accessContext: { + consumerType: "tool_connection", + consumerId: connection.id, + configPath: `credentials.${ref.name}`, + actorType: "system", + actorId: session.agentId, + issueId: session.issueId, + heartbeatRunId: session.runId, + }, }); headers[ref.key] = `${ref.prefix ?? ""}${value}`; } catch { @@ -2448,10 +2454,15 @@ export function createToolGatewayService( oauthAccessRef.secretId, oauthAccessRef.versionSelector ?? "latest", { - consumerType: "tool_connection", - consumerId: connection.id, - configPath: oauthAccessRef.configPath, - actorType: "system", + accessContext: { + consumerType: "tool_connection", + consumerId: connection.id, + configPath: oauthAccessRef.configPath, + actorType: "system", + actorId: session.agentId, + issueId: session.issueId, + heartbeatRunId: session.runId, + }, }, ); headers.Authorization = `Bearer ${value}`; @@ -2482,12 +2493,7 @@ export function createToolGatewayService( ): Promise { const versionSelector = input.versionSelector ?? "latest"; try { - const resolvedVersion = await secrets.resolveSecretVersion(connection.companyId, input.secretId, versionSelector, { - consumerType: "tool_connection", - consumerId: connection.id, - configPath: input.configPath, - actorType: "system", - }); + const resolvedVersion = await secrets.resolveSecretVersion(connection.companyId, input.secretId, versionSelector); return { refHash: input.refHash, versionSelector: String(versionSelector), @@ -2791,9 +2797,12 @@ export function createToolGatewayService( }; } - function localStdioEnvironment(connection: typeof toolConnections.$inferSelect, template: LocalStdioRuntimeTemplate): NodeJS.ProcessEnv { - const config = asRecord(connection.config) ?? {}; - const configEnv = asRecord(config.env) ?? {}; + async function localStdioEnvironment( + session: ToolGatewaySession, + connection: typeof toolConnections.$inferSelect, + template: LocalStdioRuntimeTemplate, + grant: typeof connectionGrants.$inferSelect, + ): Promise { const env: NodeJS.ProcessEnv = {}; for (const key of ["PATH", "Path", "SystemRoot", "WINDIR", "COMSPEC", "PATHEXT"]) { const value = process.env[key]; @@ -2802,9 +2811,33 @@ export function createToolGatewayService( } } for (const key of template.envKeys) { - const configured = configEnv[key]; - if (typeof configured === "string") { - env[key] = configured; + const grantRef = grant.credentialSecretRefs.find((ref) => ref.configPath === `env.${key}`); + if (!grantRef) continue; + try { + env[key] = await secrets.resolveSecretValue( + connection.companyId, + grantRef.secretId, + grantRef.versionSelector ?? "latest", + { + accessContext: { + consumerType: "tool_connection", + consumerId: connection.id, + configPath: grantRef.configPath, + actorType: "system", + actorId: session.agentId, + issueId: session.issueId, + heartbeatRunId: session.runId, + }, + }, + ); + } catch { + await markRemoteConnectionHealth(connection, "missing_secret", "A configured local stdio credential could not be resolved."); + throw new ToolGatewayHttpError( + 422, + "A configured local stdio credential could not be resolved.", + "local_stdio_missing_secret", + { connectionId: connection.id, credential: grantRef.configPath }, + ); } } return env; @@ -2818,6 +2851,7 @@ export function createToolGatewayService( connection: typeof toolConnections.$inferSelect; entry: typeof toolCatalogEntries.$inferSelect; template: LocalStdioRuntimeTemplate; + env: NodeJS.ProcessEnv; parameters: unknown; timeoutMs: number; }): Promise { @@ -2830,7 +2864,7 @@ export function createToolGatewayService( ); } const child = spawn(input.template.command, input.template.args, { - env: localStdioEnvironment(input.connection, input.template), + env: input.env, stdio: ["pipe", "pipe", "pipe"], }); let stdout = ""; @@ -3244,7 +3278,7 @@ export function createToolGatewayService( // during tools/list. Credentials remain authoritative on collisions. const credentialHeaders = { ...projectedConnectionHeaders(connection), - ...await resolveCredentialHeaders(connection, grant), + ...await resolveCredentialHeaders(session, connection, grant), }; const { headers, summary: headerSummary } = buildRemoteHeaders({ session, @@ -3385,8 +3419,9 @@ export function createToolGatewayService( ms: number, ): Promise { const { entry, connection } = await resolveConnectedLocalStdioTool(session, tool); - await resolveConnectionGrant(session, connection); + const grant = await resolveConnectionGrant(session, connection); const template = await resolveLocalStdioRuntimeTemplate(connection); + const env = await localStdioEnvironment(session, connection, template, grant); const result = await runtimeSupervisor.useConnectionSlot( { companyId: session.companyId, @@ -3410,6 +3445,7 @@ export function createToolGatewayService( connection, entry, template, + env, parameters, timeoutMs: ms, });