mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 14:10:37 -07:00
feat(policy): Support MCP Server Wildcards in Policy Engine (#20024)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user