feat(policy): Support MCP Server Wildcards in Policy Engine (#20024)

This commit is contained in:
Jerop Kipruto
2026-02-23 14:07:06 -05:00
committed by GitHub
parent 25803e05fd
commit 347f3fe7e4
5 changed files with 273 additions and 42 deletions
+108 -9
View File
@@ -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)(
+66 -19
View File
@@ -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]]