From 347f3fe7e46b049f8ba1b0c7562684decca4cd0a Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Mon, 23 Feb 2026 14:07:06 -0500 Subject: [PATCH] feat(policy): Support MCP Server Wildcards in Policy Engine (#20024) --- docs/reference/policy-engine.md | 56 ++++++--- .../config/policy-engine.integration.test.ts | 29 +++++ .../core/src/policy/policy-engine.test.ts | 117 ++++++++++++++++-- packages/core/src/policy/policy-engine.ts | 85 ++++++++++--- packages/core/src/policy/toml-loader.test.ts | 28 +++++ 5 files changed, 273 insertions(+), 42 deletions(-) diff --git a/docs/reference/policy-engine.md b/docs/reference/policy-engine.md index 2106b751c9..894bdcdad2 100644 --- a/docs/reference/policy-engine.md +++ b/docs/reference/policy-engine.md @@ -64,9 +64,11 @@ primary conditions are the tool's name and its arguments. The `toolName` in the rule must match the name of the tool being called. -- **Wildcards**: For Model-hosting-protocol (MCP) servers, you can use a - wildcard. A `toolName` of `my-server__*` will match any tool from the - `my-server` MCP. +- **Wildcards**: You can use wildcards to match multiple tools. + - `*`: Matches **any tool** (built-in or MCP). + - `server__*`: Matches any tool from a specific MCP server. + - `*__toolName`: Matches a specific tool name across **all** MCP servers. + - `*__*`: Matches **any tool from any MCP server**. #### Arguments pattern @@ -144,9 +146,9 @@ A rule matches a tool call if all of its conditions are met: 1. **Tool name**: The `toolName` in the rule must match the name of the tool being called. - - **Wildcards**: For Model-hosting-protocol (MCP) servers, you can use a - wildcard. A `toolName` of `my-server__*` will match any tool from the - `my-server` MCP. + - **Wildcards**: You can use wildcards like `*`, `server__*`, or + `*__toolName` to match multiple tools. See [Tool Name](#tool-name) for + details. 2. **Arguments pattern**: If `argsPattern` is specified, the tool's arguments are converted to a stable JSON string, which is then tested against the provided regular expression. If the arguments don't match the pattern, the @@ -272,13 +274,12 @@ priority = 100 ### Special syntax for MCP tools -You can create rules that target tools from Model-hosting-protocol (MCP) servers -using the `mcpName` field or a wildcard pattern. +You can create rules that target tools from Model Context Protocol (MCP) servers +using the `mcpName` field or composite wildcard patterns. -**1. Using `mcpName`** +**1. Targeting a specific tool on a server** -To target a specific tool from a specific server, combine `mcpName` and -`toolName`. +Combine `mcpName` and `toolName` to target a single operation. ```toml # Allows the `search` tool on the `my-jira-server` MCP @@ -289,10 +290,10 @@ decision = "allow" priority = 200 ``` -**2. Using a wildcard** +**2. Targeting all tools on a specific server** -To create a rule that applies to _all_ tools on a specific MCP server, specify -only the `mcpName`. +Specify only the `mcpName` to apply a rule to every tool provided by that +server. ```toml # Denies all tools from the `untrusted-server` MCP @@ -303,6 +304,33 @@ priority = 500 deny_message = "This server is not trusted by the admin." ``` +**3. Targeting all MCP servers** + +Use `mcpName = "*"` to create a rule that applies to **all** tools from **any** +registered MCP server. This is useful for setting category-wide defaults. + +```toml +# Ask user for any tool call from any MCP server +[[rule]] +mcpName = "*" +decision = "ask_user" +priority = 10 +``` + +**4. Targeting a tool name across all servers** + +Use `mcpName = "*"` with a specific `toolName` to target that operation +regardless of which server provides it. + +```toml +# Allow the `search` tool across all connected MCP servers +[[rule]] +mcpName = "*" +toolName = "search" +decision = "allow" +priority = 50 +``` + ## Default policies The Gemini CLI ships with a set of default policies to provide a safe diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 4069d3b878..6847865434 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -132,6 +132,35 @@ describe('Policy Engine Integration Tests', () => { ).toBe(PolicyDecision.ASK_USER); }); + it('should handle global MCP wildcard (*) in settings', async () => { + const settings: Settings = { + mcp: { + allowed: ['*'], + }, + }; + + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + const engine = new PolicyEngine(config); + + // ANY tool with a server name should be allowed + expect( + (await engine.check({ name: 'mcp-server__tool' }, 'mcp-server')) + .decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'another-server__tool' }, 'another-server')) + .decision, + ).toBe(PolicyDecision.ALLOW); + + // Built-in tools should NOT be allowed by the MCP wildcard + expect( + (await engine.check({ name: 'run_shell_command' }, undefined)).decision, + ).toBe(PolicyDecision.ASK_USER); + }); + it('should correctly prioritize specific tool excludes over MCP server wildcards', async () => { const settings: Settings = { mcp: { diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts index 11e8333f47..121dfb7c0c 100644 --- a/packages/core/src/policy/policy-engine.test.ts +++ b/packages/core/src/policy/policy-engine.test.ts @@ -431,6 +431,63 @@ describe('PolicyEngine', () => { }); describe('MCP server wildcard patterns', () => { + it('should match global wildcard (*)', async () => { + engine = new PolicyEngine({ + rules: [ + { toolName: '*', decision: PolicyDecision.ALLOW, priority: 10 }, + ], + }); + + expect( + (await engine.check({ name: 'read_file' }, undefined)).decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'my-server__tool' }, 'my-server')).decision, + ).toBe(PolicyDecision.ALLOW); + }); + + it('should match any MCP tool when toolName is *__*', async () => { + engine = new PolicyEngine({ + rules: [ + { toolName: '*__*', decision: PolicyDecision.ALLOW, priority: 10 }, + ], + defaultDecision: PolicyDecision.DENY, + }); + + expect((await engine.check({ name: 'mcp__tool' }, 'mcp')).decision).toBe( + PolicyDecision.ALLOW, + ); + expect( + (await engine.check({ name: 'other__tool' }, 'other')).decision, + ).toBe(PolicyDecision.ALLOW); + expect( + (await engine.check({ name: 'read_file' }, undefined)).decision, + ).toBe(PolicyDecision.DENY); + }); + + it('should match specific tool across all servers when using *__tool', async () => { + engine = new PolicyEngine({ + rules: [ + { + toolName: '*__search', + decision: PolicyDecision.ALLOW, + priority: 10, + }, + ], + defaultDecision: PolicyDecision.DENY, + }); + + expect((await engine.check({ name: 'ws__search' }, 'ws')).decision).toBe( + PolicyDecision.ALLOW, + ); + expect((await engine.check({ name: 'gh__search' }, 'gh')).decision).toBe( + PolicyDecision.ALLOW, + ); + expect((await engine.check({ name: 'gh__list' }, 'gh')).decision).toBe( + PolicyDecision.DENY, + ); + }); + it('should match MCP server wildcard patterns', async () => { const rules: PolicyRule[] = [ { @@ -449,26 +506,35 @@ describe('PolicyEngine', () => { // Should match my-server tools expect( - (await engine.check({ name: 'my-server__tool1' }, undefined)).decision, + (await engine.check({ name: 'my-server__tool1' }, 'my-server')) + .decision, ).toBe(PolicyDecision.ALLOW); expect( - (await engine.check({ name: 'my-server__another_tool' }, undefined)) + (await engine.check({ name: 'my-server__another_tool' }, 'my-server')) .decision, ).toBe(PolicyDecision.ALLOW); // Should match blocked-server tools expect( - (await engine.check({ name: 'blocked-server__tool1' }, undefined)) - .decision, + ( + await engine.check( + { name: 'blocked-server__tool1' }, + 'blocked-server', + ) + ).decision, ).toBe(PolicyDecision.DENY); expect( - (await engine.check({ name: 'blocked-server__dangerous' }, undefined)) - .decision, + ( + await engine.check( + { name: 'blocked-server__dangerous' }, + 'blocked-server', + ) + ).decision, ).toBe(PolicyDecision.DENY); // Should not match other patterns expect( - (await engine.check({ name: 'other-server__tool' }, undefined)) + (await engine.check({ name: 'other-server__tool' }, 'other-server')) .decision, ).toBe(PolicyDecision.ASK_USER); expect( @@ -497,11 +563,11 @@ describe('PolicyEngine', () => { // Specific tool deny should override server allow expect( - (await engine.check({ name: 'my-server__dangerous-tool' }, undefined)) + (await engine.check({ name: 'my-server__dangerous-tool' }, 'my-server')) .decision, ).toBe(PolicyDecision.DENY); expect( - (await engine.check({ name: 'my-server__safe-tool' }, undefined)) + (await engine.check({ name: 'my-server__safe-tool' }, 'my-server')) .decision, ).toBe(PolicyDecision.ALLOW); }); @@ -2262,6 +2328,39 @@ describe('PolicyEngine', () => { ], expected: [], }, + { + name: 'should handle global wildcard * in getExcludedTools', + rules: [ + { + toolName: '*', + decision: PolicyDecision.DENY, + priority: 10, + }, + ], + expected: ['*'], + }, + { + name: 'should handle MCP category wildcard *__* in getExcludedTools', + rules: [ + { + toolName: '*__*', + decision: PolicyDecision.DENY, + priority: 10, + }, + ], + expected: ['*__*'], + }, + { + name: 'should handle tool wildcard *__search in getExcludedTools', + rules: [ + { + toolName: '*__search', + decision: PolicyDecision.DENY, + priority: 10, + }, + ], + expected: ['*__search'], + }, ]; it.each(testCases)( diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts index 353cdae9c1..0998ccb2b5 100644 --- a/packages/core/src/policy/policy-engine.ts +++ b/packages/core/src/policy/policy-engine.ts @@ -27,19 +27,73 @@ import { import { getToolAliases } from '../tools/tool-names.js'; function isWildcardPattern(name: string): boolean { - return name.endsWith('__*'); + return name === '*' || name.includes('*'); } -function getWildcardPrefix(pattern: string): string { - return pattern.slice(0, -3); +/** + * Checks if a tool call matches a wildcard pattern. + * Supports global (*) and composite (server__*, *__tool, *__*) patterns. + */ +function matchesWildcard( + pattern: string, + toolName: string, + serverName: string | undefined, +): boolean { + if (pattern === '*') { + return true; + } + + if (pattern.includes('__')) { + return matchesCompositePattern(pattern, toolName, serverName); + } + + return toolName === pattern; } -function matchesWildcard(pattern: string, toolName: string): boolean { - if (!isWildcardPattern(pattern)) { +/** + * Matches composite patterns like "server__*", "*__tool", or "*__*". + */ +function matchesCompositePattern( + pattern: string, + toolName: string, + serverName: string | undefined, +): boolean { + const parts = pattern.split('__'); + if (parts.length !== 2) return false; + const [patternServer, patternTool] = parts; + + // 1. Identify the tool's components + const { actualServer, actualTool } = getToolMetadata(toolName, serverName); + + // 2. Composite patterns require a server context + if (actualServer === undefined) { return false; } - const prefix = getWildcardPrefix(pattern); - return toolName.startsWith(prefix + '__'); + + // 3. Robustness: if serverName is provided, toolName MUST be qualified by it. + // This prevents "malicious-server" from spoofing "trusted-server" by naming itself "trusted-server__malicious". + if (serverName !== undefined && !toolName.startsWith(serverName + '__')) { + return false; + } + + // 4. Match components + const serverMatch = patternServer === '*' || patternServer === actualServer; + const toolMatch = patternTool === '*' || patternTool === actualTool; + + return serverMatch && toolMatch; +} + +/** + * Extracts the server and unqualified tool name from a tool call context. + */ +function getToolMetadata(toolName: string, serverName: string | undefined) { + const sepIndex = toolName.indexOf('__'); + const isQualified = sepIndex !== -1; + return { + actualServer: + serverName ?? (isQualified ? toolName.substring(0, sepIndex) : undefined), + actualTool: isQualified ? toolName.substring(sepIndex + 2) : toolName, + }; } function ruleMatches( @@ -58,18 +112,11 @@ function ruleMatches( // Check tool name if specified if (rule.toolName) { - // Support wildcard patterns: "serverName__*" matches "serverName__anyTool" if (isWildcardPattern(rule.toolName)) { - const prefix = getWildcardPrefix(rule.toolName); - if (serverName !== undefined) { - // Robust check: if serverName is provided, it MUST match the prefix exactly. - // This prevents "malicious-server" from spoofing "trusted-server" by naming itself "trusted-server__malicious". - if (serverName !== prefix) { - return false; - } - } - // Always verify the prefix, even if serverName matched - if (!toolCall.name || !matchesWildcard(rule.toolName, toolCall.name)) { + if ( + !toolCall.name || + !matchesWildcard(rule.toolName, toolCall.name, serverName) + ) { return false; } } else if (toolCall.name !== rule.toolName) { @@ -597,7 +644,7 @@ export class PolicyEngine { for (const processed of processedTools) { if ( isWildcardPattern(processed) && - matchesWildcard(processed, toolName) + matchesWildcard(processed, toolName, undefined) ) { // It's covered by a higher-priority wildcard rule. // If that wildcard rule resulted in exclusion, this tool should also be excluded. diff --git a/packages/core/src/policy/toml-loader.test.ts b/packages/core/src/policy/toml-loader.test.ts index e706b16bf7..1ea83775fe 100644 --- a/packages/core/src/policy/toml-loader.test.ts +++ b/packages/core/src/policy/toml-loader.test.ts @@ -89,6 +89,34 @@ priority = 100 expect(result.errors).toHaveLength(0); }); + it('should transform mcpName = "*" to wildcard toolName', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +mcpName = "*" +decision = "ask_user" +priority = 10 +`); + + expect(result.rules).toHaveLength(1); + expect(result.rules[0].toolName).toBe('*__*'); + expect(result.rules[0].decision).toBe(PolicyDecision.ASK_USER); + expect(result.errors).toHaveLength(0); + }); + + it('should transform mcpName = "*" and specific toolName to wildcard prefix', async () => { + const result = await runLoadPoliciesFromToml(` +[[rule]] +mcpName = "*" +toolName = "search" +decision = "allow" +priority = 10 +`); + + expect(result.rules).toHaveLength(1); + expect(result.rules[0].toolName).toBe('*__search'); + expect(result.errors).toHaveLength(0); + }); + it('should transform commandRegex to argsPattern', async () => { const result = await runLoadPoliciesFromToml(` [[rule]]