mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 02:24:09 -07:00
feat(policy): Support MCP Server Wildcards in Policy Engine (#20024)
This commit is contained in:
@@ -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