diff --git a/src/server/lib/validation/aiAgentConfigSchemas.ts b/src/server/lib/validation/aiAgentConfigSchemas.ts index 2b0538bc..ace0cc21 100644 --- a/src/server/lib/validation/aiAgentConfigSchemas.ts +++ b/src/server/lib/validation/aiAgentConfigSchemas.ts @@ -20,6 +20,7 @@ const approvalPolicyRulesSchema = { type: 'object', properties: { read: approvalModeSchema, + external_mcp_read: approvalModeSchema, workspace_write: approvalModeSchema, shell_exec: approvalModeSchema, git_write: approvalModeSchema, diff --git a/src/server/services/agent/CapabilityService.ts b/src/server/services/agent/CapabilityService.ts index 2ae87d80..f407bfb4 100644 --- a/src/server/services/agent/CapabilityService.ts +++ b/src/server/services/agent/CapabilityService.ts @@ -348,7 +348,7 @@ export default class AgentCapabilityService { const catalogEntries = getSessionWorkspaceCatalogEntriesForRuntimeTool(discoveredTool.name, server.name); for (const entry of catalogEntries) { - const capabilityKey = AgentPolicyService.capabilityForMcpTool( + const capabilityKey = AgentPolicyService.capabilityForSessionWorkspaceTool( entry.toolName, entry.annotations || discoveredTool.annotations ); @@ -496,7 +496,10 @@ export default class AgentCapabilityService { continue; } - const capabilityKey = AgentPolicyService.capabilityForMcpTool(discoveredTool.name, discoveredTool.annotations); + const capabilityKey = AgentPolicyService.capabilityForExternalMcpTool( + discoveredTool.name, + discoveredTool.annotations + ); const toolName = buildAgentToolKey(server.slug, discoveredTool.name); const mode = resolveToolApprovalMode({ toolRules, diff --git a/src/server/services/agent/PolicyService.ts b/src/server/services/agent/PolicyService.ts index 868d56db..55a23caf 100644 --- a/src/server/services/agent/PolicyService.ts +++ b/src/server/services/agent/PolicyService.ts @@ -43,7 +43,7 @@ export default class AgentPolicyService { }; } - static capabilityForMcpTool(toolName: string, annotations?: McpAnnotations): AgentCapabilityKey { + static capabilityForSessionWorkspaceTool(toolName: string, annotations?: McpAnnotations): AgentCapabilityKey { if (annotations?.readOnlyHint) { return 'read'; } @@ -75,6 +75,14 @@ export default class AgentPolicyService { return 'network_access'; } + return 'workspace_write'; + } + + static capabilityForExternalMcpTool(_toolName: string, annotations?: McpAnnotations): AgentCapabilityKey { + if (annotations?.readOnlyHint) { + return 'external_mcp_read'; + } + return 'external_mcp_write'; } diff --git a/src/server/services/agent/__tests__/CapabilityService.test.ts b/src/server/services/agent/__tests__/CapabilityService.test.ts index 0462aa23..37e1dab2 100644 --- a/src/server/services/agent/__tests__/CapabilityService.test.ts +++ b/src/server/services/agent/__tests__/CapabilityService.test.ts @@ -57,7 +57,8 @@ jest.mock('server/lib/logger', () => ({ jest.mock('../PolicyService', () => ({ __esModule: true, default: { - capabilityForMcpTool: jest.fn(() => 'external_mcp_read'), + capabilityForSessionWorkspaceTool: jest.fn(() => 'read'), + capabilityForExternalMcpTool: jest.fn(() => 'external_mcp_read'), modeForCapability: (...args: unknown[]) => mockModeForCapability(...args), }, })); diff --git a/src/server/services/agent/__tests__/PolicyService.test.ts b/src/server/services/agent/__tests__/PolicyService.test.ts new file mode 100644 index 00000000..80d1def6 --- /dev/null +++ b/src/server/services/agent/__tests__/PolicyService.test.ts @@ -0,0 +1,43 @@ +/** + * Copyright 2026 GoodRx, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import AgentPolicyService from '../PolicyService'; + +describe('AgentPolicyService', () => { + it('keeps read-only sandbox tools in the read capability', () => { + expect( + AgentPolicyService.capabilityForSessionWorkspaceTool('workspace.read_file', { + readOnlyHint: true, + }) + ).toBe('read'); + }); + + it('keeps session git helpers in the git write capability', () => { + expect(AgentPolicyService.capabilityForSessionWorkspaceTool('git.branch')).toBe('git_write'); + }); + + it('maps read-only external MCP tools to external_mcp_read', () => { + expect( + AgentPolicyService.capabilityForExternalMcpTool('getJiraIssue', { + readOnlyHint: true, + }) + ).toBe('external_mcp_read'); + }); + + it('maps mutating external MCP tools to external_mcp_write without workspace heuristics', () => { + expect(AgentPolicyService.capabilityForExternalMcpTool('editJiraIssue')).toBe('external_mcp_write'); + }); +}); diff --git a/src/server/services/agent/sandboxToolCatalog.ts b/src/server/services/agent/sandboxToolCatalog.ts index 057c3b8e..e622a2c7 100644 --- a/src/server/services/agent/sandboxToolCatalog.ts +++ b/src/server/services/agent/sandboxToolCatalog.ts @@ -284,7 +284,7 @@ function isSessionWorkspaceToolAllowed( toolRules: AgentSessionToolRule[] = [] ): boolean { const rule = toolRules.find((item) => item.toolKey === entry.toolKey); - const capabilityKey = AgentPolicyService.capabilityForMcpTool(entry.toolName, entry.annotations); + const capabilityKey = AgentPolicyService.capabilityForSessionWorkspaceTool(entry.toolName, entry.annotations); const mode = rule?.mode || AgentPolicyService.modeForCapability(approvalPolicy, capabilityKey); return mode !== 'deny'; diff --git a/src/server/services/agent/types.ts b/src/server/services/agent/types.ts index 0aa1ee6f..6e9f37a3 100644 --- a/src/server/services/agent/types.ts +++ b/src/server/services/agent/types.ts @@ -18,6 +18,7 @@ import type { UIDataTypes, UIMessage } from 'ai'; export const AGENT_CAPABILITY_KEYS = [ 'read', + 'external_mcp_read', 'workspace_write', 'shell_exec', 'git_write', @@ -159,6 +160,7 @@ export const DEFAULT_AGENT_APPROVAL_POLICY: AgentApprovalPolicy = { defaultMode: 'require_approval', rules: { read: 'allow', + external_mcp_read: 'allow', workspace_write: 'require_approval', shell_exec: 'require_approval', git_write: 'require_approval', diff --git a/src/server/services/agentSessionConfig.ts b/src/server/services/agentSessionConfig.ts index cd0e7cf3..52599520 100644 --- a/src/server/services/agentSessionConfig.ts +++ b/src/server/services/agentSessionConfig.ts @@ -498,7 +498,10 @@ export default class AgentSessionConfigService extends BaseService { annotations?: McpDiscoveredTool['annotations']; }) => { const toolKey = buildAgentToolKey(serverSlug, toolName); - const capabilityKey = AgentPolicyService.capabilityForMcpTool(toolName, annotations); + const capabilityKey = + sourceType === 'builtin' + ? AgentPolicyService.capabilityForSessionWorkspaceTool(toolName, annotations) + : AgentPolicyService.capabilityForExternalMcpTool(toolName, annotations); const approvalMode = AgentPolicyService.modeForCapability(approvalPolicy, capabilityKey); const scopeRuleMode = toRuleSelection(activeScopeConfig.toolRules || [], toolKey); const effectiveRuleMode = toRuleSelection(effectiveConfig.toolRules, toolKey); diff --git a/src/server/services/types/aiAgent.ts b/src/server/services/types/aiAgent.ts index cc7260d9..623f98eb 100644 --- a/src/server/services/types/aiAgent.ts +++ b/src/server/services/types/aiAgent.ts @@ -331,6 +331,7 @@ export interface ApprovalPolicyConfig { rules?: Partial< Record< | 'read' + | 'external_mcp_read' | 'workspace_write' | 'shell_exec' | 'git_write' diff --git a/src/shared/openApiSpec.ts b/src/shared/openApiSpec.ts index 0bef0da9..a5a93f4c 100644 --- a/src/shared/openApiSpec.ts +++ b/src/shared/openApiSpec.ts @@ -195,6 +195,7 @@ export const openApiSpecificationForV2Api: OAS3Options = { type: 'object', properties: { read: { $ref: '#/components/schemas/AgentApprovalMode' }, + external_mcp_read: { $ref: '#/components/schemas/AgentApprovalMode' }, workspace_write: { $ref: '#/components/schemas/AgentApprovalMode' }, shell_exec: { $ref: '#/components/schemas/AgentApprovalMode' }, git_write: { $ref: '#/components/schemas/AgentApprovalMode' },