mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-27 05:24:34 -07:00
feat(cli): configure policy engine from existing settings (#8348)
This commit is contained in:
@@ -43,6 +43,7 @@ import { resolvePath } from '../utils/resolvePath.js';
|
|||||||
import { appEvents } from '../utils/events.js';
|
import { appEvents } from '../utils/events.js';
|
||||||
|
|
||||||
import { isWorkspaceTrusted } from './trustedFolders.js';
|
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||||
|
import { createPolicyEngineConfig } from './policy.js';
|
||||||
|
|
||||||
// Simple console logger for now - replace with actual logger if available
|
// Simple console logger for now - replace with actual logger if available
|
||||||
const logger = {
|
const logger = {
|
||||||
@@ -489,6 +490,8 @@ export async function loadCliConfig(
|
|||||||
approvalMode = ApprovalMode.DEFAULT;
|
approvalMode = ApprovalMode.DEFAULT;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const policyEngineConfig = createPolicyEngineConfig(settings, approvalMode);
|
||||||
|
|
||||||
const interactive =
|
const interactive =
|
||||||
!!argv.promptInteractive || (process.stdin.isTTY && question.length === 0);
|
!!argv.promptInteractive || (process.stdin.isTTY && question.length === 0);
|
||||||
// In non-interactive mode, exclude tools that require a prompt.
|
// In non-interactive mode, exclude tools that require a prompt.
|
||||||
@@ -574,6 +577,7 @@ export async function loadCliConfig(
|
|||||||
fullContext: argv.allFiles || false,
|
fullContext: argv.allFiles || false,
|
||||||
coreTools: settings.tools?.core || undefined,
|
coreTools: settings.tools?.core || undefined,
|
||||||
allowedTools: argv.allowedTools || settings.tools?.allowed || undefined,
|
allowedTools: argv.allowedTools || settings.tools?.allowed || undefined,
|
||||||
|
policyEngineConfig,
|
||||||
excludeTools,
|
excludeTools,
|
||||||
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
toolDiscoveryCommand: settings.tools?.discoveryCommand,
|
||||||
toolCallCommand: settings.tools?.callCommand,
|
toolCallCommand: settings.tools?.callCommand,
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
ApprovalMode,
|
||||||
|
PolicyDecision,
|
||||||
|
PolicyEngine,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
import { createPolicyEngineConfig } from './policy.js';
|
||||||
|
import type { Settings } from './settings.js';
|
||||||
|
|
||||||
|
describe('Policy Engine Integration Tests', () => {
|
||||||
|
describe('Policy configuration produces valid PolicyEngine config', () => {
|
||||||
|
it('should create a working PolicyEngine from basic settings', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
tools: {
|
||||||
|
allowed: ['run_shell_command'],
|
||||||
|
exclude: ['write_file'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
const engine = new PolicyEngine(config);
|
||||||
|
|
||||||
|
// Allowed tool should be allowed
|
||||||
|
expect(engine.check({ name: 'run_shell_command' })).toBe(
|
||||||
|
PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Excluded tool should be denied
|
||||||
|
expect(engine.check({ name: 'write_file' })).toBe(PolicyDecision.DENY);
|
||||||
|
|
||||||
|
// Other write tools should ask user
|
||||||
|
expect(engine.check({ name: 'replace' })).toBe(PolicyDecision.ASK_USER);
|
||||||
|
|
||||||
|
// Unknown tools should use default
|
||||||
|
expect(engine.check({ name: 'unknown_tool' })).toBe(
|
||||||
|
PolicyDecision.ASK_USER,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle MCP server wildcard patterns correctly', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
mcp: {
|
||||||
|
allowed: ['allowed-server'],
|
||||||
|
excluded: ['blocked-server'],
|
||||||
|
},
|
||||||
|
mcpServers: {
|
||||||
|
'trusted-server': {
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js'],
|
||||||
|
trust: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
const engine = new PolicyEngine(config);
|
||||||
|
|
||||||
|
// Tools from allowed server should be allowed
|
||||||
|
expect(engine.check({ name: 'allowed-server__tool1' })).toBe(
|
||||||
|
PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'allowed-server__another_tool' })).toBe(
|
||||||
|
PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tools from trusted server should be allowed
|
||||||
|
expect(engine.check({ name: 'trusted-server__tool1' })).toBe(
|
||||||
|
PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'trusted-server__special_tool' })).toBe(
|
||||||
|
PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tools from blocked server should be denied
|
||||||
|
expect(engine.check({ name: 'blocked-server__tool1' })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'blocked-server__any_tool' })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tools from unknown servers should use default
|
||||||
|
expect(engine.check({ name: 'unknown-server__tool' })).toBe(
|
||||||
|
PolicyDecision.ASK_USER,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should correctly prioritize specific tool rules over MCP server wildcards', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
mcp: {
|
||||||
|
allowed: ['my-server'],
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
exclude: ['my-server__dangerous-tool'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
const engine = new PolicyEngine(config);
|
||||||
|
|
||||||
|
// Server is allowed, but specific tool is excluded
|
||||||
|
expect(engine.check({ name: 'my-server__safe-tool' })).toBe(
|
||||||
|
PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'my-server__dangerous-tool' })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex mixed configurations', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
tools: {
|
||||||
|
autoAccept: true, // Allows read-only tools
|
||||||
|
allowed: ['custom-tool', 'my-server__special-tool'],
|
||||||
|
exclude: ['glob', 'dangerous-tool'],
|
||||||
|
},
|
||||||
|
mcp: {
|
||||||
|
allowed: ['allowed-server'],
|
||||||
|
excluded: ['blocked-server'],
|
||||||
|
},
|
||||||
|
mcpServers: {
|
||||||
|
'trusted-server': {
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js'],
|
||||||
|
trust: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
const engine = new PolicyEngine(config);
|
||||||
|
|
||||||
|
// Read-only tools should be allowed (autoAccept)
|
||||||
|
expect(engine.check({ name: 'read_file' })).toBe(PolicyDecision.ALLOW);
|
||||||
|
expect(engine.check({ name: 'list_directory' })).toBe(
|
||||||
|
PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
|
||||||
|
// But glob is explicitly excluded, so it should be denied
|
||||||
|
expect(engine.check({ name: 'glob' })).toBe(PolicyDecision.DENY);
|
||||||
|
|
||||||
|
// Replace should ask user (normal write tool behavior)
|
||||||
|
expect(engine.check({ name: 'replace' })).toBe(PolicyDecision.ASK_USER);
|
||||||
|
|
||||||
|
// Explicitly allowed tools
|
||||||
|
expect(engine.check({ name: 'custom-tool' })).toBe(PolicyDecision.ALLOW);
|
||||||
|
expect(engine.check({ name: 'my-server__special-tool' })).toBe(
|
||||||
|
PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
|
||||||
|
// MCP server tools
|
||||||
|
expect(engine.check({ name: 'allowed-server__tool' })).toBe(
|
||||||
|
PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'trusted-server__tool' })).toBe(
|
||||||
|
PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'blocked-server__tool' })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Write tools should ask by default
|
||||||
|
expect(engine.check({ name: 'write_file' })).toBe(
|
||||||
|
PolicyDecision.ASK_USER,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle YOLO mode correctly', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
tools: {
|
||||||
|
exclude: ['dangerous-tool'], // Even in YOLO, excludes should be respected
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.YOLO);
|
||||||
|
const engine = new PolicyEngine(config);
|
||||||
|
|
||||||
|
// Most tools should be allowed in YOLO mode
|
||||||
|
expect(engine.check({ name: 'run_shell_command' })).toBe(
|
||||||
|
PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'write_file' })).toBe(PolicyDecision.ALLOW);
|
||||||
|
expect(engine.check({ name: 'unknown_tool' })).toBe(PolicyDecision.ALLOW);
|
||||||
|
|
||||||
|
// But explicitly excluded tools should still be denied
|
||||||
|
expect(engine.check({ name: 'dangerous-tool' })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle AUTO_EDIT mode correctly', () => {
|
||||||
|
const settings: Settings = {};
|
||||||
|
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.AUTO_EDIT);
|
||||||
|
const engine = new PolicyEngine(config);
|
||||||
|
|
||||||
|
// Edit tool should be allowed (EditTool.Name = 'replace')
|
||||||
|
expect(engine.check({ name: 'replace' })).toBe(PolicyDecision.ALLOW);
|
||||||
|
|
||||||
|
// Other tools should follow normal rules
|
||||||
|
expect(engine.check({ name: 'run_shell_command' })).toBe(
|
||||||
|
PolicyDecision.ASK_USER,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'write_file' })).toBe(
|
||||||
|
PolicyDecision.ASK_USER,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should verify priority ordering works correctly in practice', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
tools: {
|
||||||
|
autoAccept: true, // Priority 50
|
||||||
|
allowed: ['specific-tool'], // Priority 100
|
||||||
|
exclude: ['blocked-tool'], // Priority 200
|
||||||
|
},
|
||||||
|
mcp: {
|
||||||
|
allowed: ['mcp-server'], // Priority 85
|
||||||
|
excluded: ['blocked-server'], // Priority 195
|
||||||
|
},
|
||||||
|
mcpServers: {
|
||||||
|
'trusted-server': {
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js'],
|
||||||
|
trust: true, // Priority 90
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
const engine = new PolicyEngine(config);
|
||||||
|
|
||||||
|
// Test that priorities are applied correctly
|
||||||
|
const rules = config.rules || [];
|
||||||
|
|
||||||
|
// Find rules and verify their priorities
|
||||||
|
const blockedToolRule = rules.find((r) => r.toolName === 'blocked-tool');
|
||||||
|
expect(blockedToolRule?.priority).toBe(200);
|
||||||
|
|
||||||
|
const blockedServerRule = rules.find(
|
||||||
|
(r) => r.toolName === 'blocked-server__*',
|
||||||
|
);
|
||||||
|
expect(blockedServerRule?.priority).toBe(195);
|
||||||
|
|
||||||
|
const specificToolRule = rules.find(
|
||||||
|
(r) => r.toolName === 'specific-tool',
|
||||||
|
);
|
||||||
|
expect(specificToolRule?.priority).toBe(100);
|
||||||
|
|
||||||
|
const trustedServerRule = rules.find(
|
||||||
|
(r) => r.toolName === 'trusted-server__*',
|
||||||
|
);
|
||||||
|
expect(trustedServerRule?.priority).toBe(90);
|
||||||
|
|
||||||
|
const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*');
|
||||||
|
expect(mcpServerRule?.priority).toBe(85);
|
||||||
|
|
||||||
|
const readOnlyToolRule = rules.find((r) => r.toolName === 'glob');
|
||||||
|
expect(readOnlyToolRule?.priority).toBe(50);
|
||||||
|
|
||||||
|
// Verify the engine applies these priorities correctly
|
||||||
|
expect(engine.check({ name: 'blocked-tool' })).toBe(PolicyDecision.DENY);
|
||||||
|
expect(engine.check({ name: 'blocked-server__any' })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'specific-tool' })).toBe(
|
||||||
|
PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'trusted-server__any' })).toBe(
|
||||||
|
PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'mcp-server__any' })).toBe(
|
||||||
|
PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'glob' })).toBe(PolicyDecision.ALLOW);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle edge case: MCP server with both trust and exclusion', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
mcpServers: {
|
||||||
|
'conflicted-server': {
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js'],
|
||||||
|
trust: true, // Priority 90 - ALLOW
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mcp: {
|
||||||
|
excluded: ['conflicted-server'], // Priority 195 - DENY
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
const engine = new PolicyEngine(config);
|
||||||
|
|
||||||
|
// Exclusion (195) should win over trust (90)
|
||||||
|
expect(engine.check({ name: 'conflicted-server__tool' })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle edge case: specific tool allowed but server excluded', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
mcp: {
|
||||||
|
excluded: ['my-server'], // Priority 195 - DENY
|
||||||
|
},
|
||||||
|
tools: {
|
||||||
|
allowed: ['my-server__special-tool'], // Priority 100 - ALLOW
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
const engine = new PolicyEngine(config);
|
||||||
|
|
||||||
|
// Server exclusion (195) wins over specific tool allow (100)
|
||||||
|
// This might be counterintuitive but follows the priority system
|
||||||
|
expect(engine.check({ name: 'my-server__special-tool' })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'my-server__other-tool' })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should verify non-interactive mode transformation', () => {
|
||||||
|
const settings: Settings = {};
|
||||||
|
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
// Enable non-interactive mode
|
||||||
|
const engineConfig = { ...config, nonInteractive: true };
|
||||||
|
const engine = new PolicyEngine(engineConfig);
|
||||||
|
|
||||||
|
// ASK_USER should become DENY in non-interactive mode
|
||||||
|
expect(engine.check({ name: 'unknown_tool' })).toBe(PolicyDecision.DENY);
|
||||||
|
expect(engine.check({ name: 'run_shell_command' })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty settings gracefully', () => {
|
||||||
|
const settings: Settings = {};
|
||||||
|
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
const engine = new PolicyEngine(config);
|
||||||
|
|
||||||
|
// Should have default rules for write tools
|
||||||
|
expect(engine.check({ name: 'write_file' })).toBe(
|
||||||
|
PolicyDecision.ASK_USER,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'replace' })).toBe(PolicyDecision.ASK_USER);
|
||||||
|
|
||||||
|
// Unknown tools should use default
|
||||||
|
expect(engine.check({ name: 'unknown' })).toBe(PolicyDecision.ASK_USER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should verify rules are created with correct priorities', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
tools: {
|
||||||
|
autoAccept: true,
|
||||||
|
allowed: ['tool1', 'tool2'],
|
||||||
|
exclude: ['tool3'],
|
||||||
|
},
|
||||||
|
mcp: {
|
||||||
|
allowed: ['server1'],
|
||||||
|
excluded: ['server2'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
const rules = config.rules || [];
|
||||||
|
|
||||||
|
// Verify each rule has the expected priority
|
||||||
|
const tool3Rule = rules.find((r) => r.toolName === 'tool3');
|
||||||
|
expect(tool3Rule?.priority).toBe(200); // Excluded tools
|
||||||
|
|
||||||
|
const server2Rule = rules.find((r) => r.toolName === 'server2__*');
|
||||||
|
expect(server2Rule?.priority).toBe(195); // Excluded servers
|
||||||
|
|
||||||
|
const tool1Rule = rules.find((r) => r.toolName === 'tool1');
|
||||||
|
expect(tool1Rule?.priority).toBe(100); // Allowed tools
|
||||||
|
|
||||||
|
const server1Rule = rules.find((r) => r.toolName === 'server1__*');
|
||||||
|
expect(server1Rule?.priority).toBe(85); // Allowed servers
|
||||||
|
|
||||||
|
const globRule = rules.find((r) => r.toolName === 'glob');
|
||||||
|
expect(globRule?.priority).toBe(50); // Auto-accept read-only
|
||||||
|
|
||||||
|
// The PolicyEngine will sort these by priority when it's created
|
||||||
|
const engine = new PolicyEngine(config);
|
||||||
|
const sortedRules = engine.getRules();
|
||||||
|
|
||||||
|
// Verify the engine sorted them correctly
|
||||||
|
for (let i = 1; i < sortedRules.length; i++) {
|
||||||
|
const prevPriority = sortedRules[i - 1].priority ?? 0;
|
||||||
|
const currPriority = sortedRules[i].priority ?? 0;
|
||||||
|
expect(prevPriority).toBeGreaterThanOrEqual(currPriority);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { createPolicyEngineConfig } from './policy.js';
|
||||||
|
import type { Settings } from './settings.js';
|
||||||
|
import { ApprovalMode, PolicyDecision } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
|
describe('createPolicyEngineConfig', () => {
|
||||||
|
it('should return ASK_USER for all tools by default', () => {
|
||||||
|
const settings: Settings = {};
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
expect(config.defaultDecision).toBe(PolicyDecision.ASK_USER);
|
||||||
|
expect(config.rules).toEqual([
|
||||||
|
{ toolName: 'replace', decision: 'ask_user', priority: 10 },
|
||||||
|
{ toolName: 'save_memory', decision: 'ask_user', priority: 10 },
|
||||||
|
{ toolName: 'run_shell_command', decision: 'ask_user', priority: 10 },
|
||||||
|
{ toolName: 'write_file', decision: 'ask_user', priority: 10 },
|
||||||
|
{ toolName: 'web_fetch', decision: 'ask_user', priority: 10 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow tools in tools.allowed', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
tools: { allowed: ['run_shell_command'] },
|
||||||
|
};
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
const rule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'run_shell_command' &&
|
||||||
|
r.decision === PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(rule).toBeDefined();
|
||||||
|
expect(rule?.priority).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny tools in tools.exclude', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
tools: { exclude: ['run_shell_command'] },
|
||||||
|
};
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
const rule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'run_shell_command' &&
|
||||||
|
r.decision === PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
expect(rule).toBeDefined();
|
||||||
|
expect(rule?.priority).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow tools from allowed MCP servers', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
mcp: { allowed: ['my-server'] },
|
||||||
|
};
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
const rule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'my-server__*' && r.decision === PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(rule).toBeDefined();
|
||||||
|
expect(rule?.priority).toBe(85);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deny tools from excluded MCP servers', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
mcp: { excluded: ['my-server'] },
|
||||||
|
};
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
const rule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'my-server__*' && r.decision === PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
expect(rule).toBeDefined();
|
||||||
|
expect(rule?.priority).toBe(195);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow tools from trusted MCP servers', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
mcpServers: {
|
||||||
|
'trusted-server': {
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js'],
|
||||||
|
trust: true,
|
||||||
|
},
|
||||||
|
'untrusted-server': {
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js'],
|
||||||
|
trust: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
|
||||||
|
const trustedRule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'trusted-server__*' &&
|
||||||
|
r.decision === PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(trustedRule).toBeDefined();
|
||||||
|
expect(trustedRule?.priority).toBe(90);
|
||||||
|
|
||||||
|
// Untrusted server should not have an allow rule
|
||||||
|
const untrustedRule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'untrusted-server__*' &&
|
||||||
|
r.decision === PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(untrustedRule).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple MCP server configurations together', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
mcp: {
|
||||||
|
allowed: ['allowed-server'],
|
||||||
|
excluded: ['excluded-server'],
|
||||||
|
},
|
||||||
|
mcpServers: {
|
||||||
|
'trusted-server': {
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js'],
|
||||||
|
trust: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
|
||||||
|
// Check allowed server
|
||||||
|
const allowedRule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'allowed-server__*' &&
|
||||||
|
r.decision === PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(allowedRule).toBeDefined();
|
||||||
|
expect(allowedRule?.priority).toBe(85);
|
||||||
|
|
||||||
|
// Check trusted server
|
||||||
|
const trustedRule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'trusted-server__*' &&
|
||||||
|
r.decision === PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(trustedRule).toBeDefined();
|
||||||
|
expect(trustedRule?.priority).toBe(90);
|
||||||
|
|
||||||
|
// Check excluded server
|
||||||
|
const excludedRule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'excluded-server__*' &&
|
||||||
|
r.decision === PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
expect(excludedRule).toBeDefined();
|
||||||
|
expect(excludedRule?.priority).toBe(195);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow read-only tools if autoAccept is true', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
tools: { autoAccept: true },
|
||||||
|
};
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
const rule = config.rules?.find(
|
||||||
|
(r) => r.toolName === 'glob' && r.decision === PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(rule).toBeDefined();
|
||||||
|
expect(rule?.priority).toBe(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow all tools in YOLO mode', () => {
|
||||||
|
const settings: Settings = {};
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.YOLO);
|
||||||
|
const rule = config.rules?.find(
|
||||||
|
(r) => r.decision === PolicyDecision.ALLOW && r.priority === 0,
|
||||||
|
);
|
||||||
|
expect(rule).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow edit tool in AUTO_EDIT mode', () => {
|
||||||
|
const settings: Settings = {};
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.AUTO_EDIT);
|
||||||
|
const rule = config.rules?.find(
|
||||||
|
(r) => r.toolName === 'replace' && r.decision === PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(rule).toBeDefined();
|
||||||
|
expect(rule?.priority).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prioritize exclude over allow', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
tools: { allowed: ['run_shell_command'], exclude: ['run_shell_command'] },
|
||||||
|
};
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
const denyRule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'run_shell_command' &&
|
||||||
|
r.decision === PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
const allowRule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'run_shell_command' &&
|
||||||
|
r.decision === PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(denyRule).toBeDefined();
|
||||||
|
expect(allowRule).toBeDefined();
|
||||||
|
expect(denyRule!.priority).toBeGreaterThan(allowRule!.priority!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prioritize specific tool allows over MCP server excludes', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
mcp: { excluded: ['my-server'] },
|
||||||
|
tools: { allowed: ['my-server__specific-tool'] },
|
||||||
|
};
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
|
||||||
|
const serverDenyRule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'my-server__*' && r.decision === PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
const toolAllowRule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'my-server__specific-tool' &&
|
||||||
|
r.decision === PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(serverDenyRule).toBeDefined();
|
||||||
|
expect(serverDenyRule?.priority).toBe(195);
|
||||||
|
expect(toolAllowRule).toBeDefined();
|
||||||
|
expect(toolAllowRule?.priority).toBe(100);
|
||||||
|
|
||||||
|
// Tool allow (100) has lower priority than server deny (195),
|
||||||
|
// so server deny wins - this might be counterintuitive
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prioritize specific tool excludes over MCP server allows', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
mcp: { allowed: ['my-server'] },
|
||||||
|
mcpServers: {
|
||||||
|
'my-server': {
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js'],
|
||||||
|
trust: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tools: { exclude: ['my-server__dangerous-tool'] },
|
||||||
|
};
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
|
||||||
|
const serverAllowRule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'my-server__*' && r.decision === PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
const toolDenyRule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'my-server__dangerous-tool' &&
|
||||||
|
r.decision === PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(serverAllowRule).toBeDefined();
|
||||||
|
expect(toolDenyRule).toBeDefined();
|
||||||
|
expect(toolDenyRule!.priority).toBeGreaterThan(serverAllowRule!.priority!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle complex priority scenarios correctly', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
tools: {
|
||||||
|
autoAccept: true, // Priority 50 for read-only tools
|
||||||
|
allowed: ['my-server__tool1', 'other-tool'], // Priority 100
|
||||||
|
exclude: ['my-server__tool2', 'glob'], // Priority 200
|
||||||
|
},
|
||||||
|
mcp: {
|
||||||
|
allowed: ['allowed-server'], // Priority 85
|
||||||
|
excluded: ['excluded-server'], // Priority 195
|
||||||
|
},
|
||||||
|
mcpServers: {
|
||||||
|
'trusted-server': {
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js'],
|
||||||
|
trust: true, // Priority 90
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
|
||||||
|
// Verify glob is denied even though autoAccept would allow it
|
||||||
|
const globDenyRule = config.rules?.find(
|
||||||
|
(r) => r.toolName === 'glob' && r.decision === PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
const globAllowRule = config.rules?.find(
|
||||||
|
(r) => r.toolName === 'glob' && r.decision === PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(globDenyRule).toBeDefined();
|
||||||
|
expect(globAllowRule).toBeDefined();
|
||||||
|
expect(globDenyRule!.priority).toBe(200);
|
||||||
|
expect(globAllowRule!.priority).toBe(50);
|
||||||
|
|
||||||
|
// Verify all priority levels are correct
|
||||||
|
const priorities = config.rules
|
||||||
|
?.map((r) => ({
|
||||||
|
tool: r.toolName,
|
||||||
|
decision: r.decision,
|
||||||
|
priority: r.priority,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0));
|
||||||
|
|
||||||
|
// Check that the highest priority items are the excludes
|
||||||
|
const highestPriorityExcludes = priorities?.filter(
|
||||||
|
(p) => p.priority === 200,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
highestPriorityExcludes?.every((p) => p.decision === PolicyDecision.DENY),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle MCP servers with undefined trust property', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
mcpServers: {
|
||||||
|
'no-trust-property': {
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js'],
|
||||||
|
// trust property is undefined/missing
|
||||||
|
},
|
||||||
|
'explicit-false': {
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js'],
|
||||||
|
trust: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
|
||||||
|
// Neither server should have an allow rule
|
||||||
|
const noTrustRule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'no-trust-property__*' &&
|
||||||
|
r.decision === PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
const explicitFalseRule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'explicit-false__*' &&
|
||||||
|
r.decision === PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(noTrustRule).toBeUndefined();
|
||||||
|
expect(explicitFalseRule).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not add write tool rules in YOLO mode', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
tools: { exclude: ['dangerous-tool'] },
|
||||||
|
};
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.YOLO);
|
||||||
|
|
||||||
|
// Should have the wildcard allow rule with priority 0
|
||||||
|
const wildcardRule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
!r.toolName && r.decision === PolicyDecision.ALLOW && r.priority === 0,
|
||||||
|
);
|
||||||
|
expect(wildcardRule).toBeDefined();
|
||||||
|
|
||||||
|
// Should NOT have any write tool rules (which would have priority 10)
|
||||||
|
const writeToolRules = config.rules?.filter(
|
||||||
|
(r) =>
|
||||||
|
[
|
||||||
|
'replace',
|
||||||
|
'save_memory',
|
||||||
|
'run_shell_command',
|
||||||
|
'write_file',
|
||||||
|
'web_fetch',
|
||||||
|
].includes(r.toolName || '') && r.decision === PolicyDecision.ASK_USER,
|
||||||
|
);
|
||||||
|
expect(writeToolRules).toHaveLength(0);
|
||||||
|
|
||||||
|
// Should still have the exclude rule
|
||||||
|
const excludeRule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'dangerous-tool' && r.decision === PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
expect(excludeRule).toBeDefined();
|
||||||
|
expect(excludeRule?.priority).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle combination of trusted server and excluded server for same name', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
mcpServers: {
|
||||||
|
'conflicted-server': {
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js'],
|
||||||
|
trust: true, // Priority 90
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mcp: {
|
||||||
|
excluded: ['conflicted-server'], // Priority 195
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
|
||||||
|
// Both rules should exist
|
||||||
|
const trustRule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'conflicted-server__*' &&
|
||||||
|
r.decision === PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
const excludeRule = config.rules?.find(
|
||||||
|
(r) =>
|
||||||
|
r.toolName === 'conflicted-server__*' &&
|
||||||
|
r.decision === PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(trustRule).toBeDefined();
|
||||||
|
expect(trustRule?.priority).toBe(90);
|
||||||
|
expect(excludeRule).toBeDefined();
|
||||||
|
expect(excludeRule?.priority).toBe(195);
|
||||||
|
|
||||||
|
// Exclude (195) should win over trust (90) when evaluated
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create all read-only tool rules when autoAccept is enabled', () => {
|
||||||
|
const settings: Settings = {
|
||||||
|
tools: { autoAccept: true },
|
||||||
|
};
|
||||||
|
const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT);
|
||||||
|
|
||||||
|
// All read-only tools should have allow rules
|
||||||
|
const readOnlyTools = [
|
||||||
|
'glob',
|
||||||
|
'search_file_content',
|
||||||
|
'list_directory',
|
||||||
|
'read_file',
|
||||||
|
'read_many_files',
|
||||||
|
];
|
||||||
|
for (const tool of readOnlyTools) {
|
||||||
|
const rule = config.rules?.find(
|
||||||
|
(r) => r.toolName === tool && r.decision === PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(rule).toBeDefined();
|
||||||
|
expect(rule?.priority).toBe(50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle all approval modes correctly', () => {
|
||||||
|
const settings: Settings = {};
|
||||||
|
|
||||||
|
// Test DEFAULT mode
|
||||||
|
const defaultConfig = createPolicyEngineConfig(
|
||||||
|
settings,
|
||||||
|
ApprovalMode.DEFAULT,
|
||||||
|
);
|
||||||
|
expect(defaultConfig.defaultDecision).toBe(PolicyDecision.ASK_USER);
|
||||||
|
expect(
|
||||||
|
defaultConfig.rules?.find(
|
||||||
|
(r) => !r.toolName && r.decision === PolicyDecision.ALLOW,
|
||||||
|
),
|
||||||
|
).toBeUndefined();
|
||||||
|
|
||||||
|
// Test YOLO mode
|
||||||
|
const yoloConfig = createPolicyEngineConfig(settings, ApprovalMode.YOLO);
|
||||||
|
expect(yoloConfig.defaultDecision).toBe(PolicyDecision.ASK_USER);
|
||||||
|
const yoloWildcard = yoloConfig.rules?.find(
|
||||||
|
(r) => !r.toolName && r.decision === PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(yoloWildcard).toBeDefined();
|
||||||
|
expect(yoloWildcard?.priority).toBe(0);
|
||||||
|
|
||||||
|
// Test AUTO_EDIT mode
|
||||||
|
const autoEditConfig = createPolicyEngineConfig(
|
||||||
|
settings,
|
||||||
|
ApprovalMode.AUTO_EDIT,
|
||||||
|
);
|
||||||
|
expect(autoEditConfig.defaultDecision).toBe(PolicyDecision.ASK_USER);
|
||||||
|
const editRule = autoEditConfig.rules?.find(
|
||||||
|
(r) => r.toolName === 'replace' && r.decision === PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(editRule).toBeDefined();
|
||||||
|
expect(editRule?.priority).toBe(15);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
type PolicyEngineConfig,
|
||||||
|
PolicyDecision,
|
||||||
|
type PolicyRule,
|
||||||
|
ApprovalMode,
|
||||||
|
// Read-only tools
|
||||||
|
GlobTool,
|
||||||
|
GrepTool,
|
||||||
|
LSTool,
|
||||||
|
ReadFileTool,
|
||||||
|
ReadManyFilesTool,
|
||||||
|
RipGrepTool,
|
||||||
|
// Write tools
|
||||||
|
EditTool,
|
||||||
|
MemoryTool,
|
||||||
|
ShellTool,
|
||||||
|
WriteFileTool,
|
||||||
|
WebFetchTool,
|
||||||
|
WebSearchTool,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
|
import type { Settings } from './settings.js';
|
||||||
|
|
||||||
|
// READ_ONLY_TOOLS is a list of built-in tools that do not modify the user's
|
||||||
|
// files or system state.
|
||||||
|
const READ_ONLY_TOOLS = new Set([
|
||||||
|
GlobTool.Name,
|
||||||
|
GrepTool.Name,
|
||||||
|
RipGrepTool.Name,
|
||||||
|
LSTool.Name,
|
||||||
|
ReadFileTool.Name,
|
||||||
|
ReadManyFilesTool.Name,
|
||||||
|
WebSearchTool.Name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// WRITE_TOOLS is a list of built-in tools that can modify the user's files or
|
||||||
|
// system state. These tools have a shouldConfirmExecute method.
|
||||||
|
// We are keeping this here for visibility and to maintain backwards compatibility
|
||||||
|
// with the existing tool permissions system. Eventually we'll remove this and
|
||||||
|
// any tool that isn't read only will require a confirmation unless altered by
|
||||||
|
// config and policy.
|
||||||
|
const WRITE_TOOLS = new Set([
|
||||||
|
EditTool.Name,
|
||||||
|
MemoryTool.Name,
|
||||||
|
ShellTool.Name,
|
||||||
|
WriteFileTool.Name,
|
||||||
|
WebFetchTool.Name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function createPolicyEngineConfig(
|
||||||
|
settings: Settings,
|
||||||
|
approvalMode: ApprovalMode,
|
||||||
|
): PolicyEngineConfig {
|
||||||
|
const rules: PolicyRule[] = [];
|
||||||
|
|
||||||
|
// Priority system for policy rules:
|
||||||
|
// - Higher priority numbers win over lower priority numbers
|
||||||
|
// - When multiple rules match, the highest priority rule is applied
|
||||||
|
// - Rules are evaluated in order of priority (highest first)
|
||||||
|
//
|
||||||
|
// Priority levels used in this configuration:
|
||||||
|
// 0: Default allow-all (YOLO mode only)
|
||||||
|
// 10: Write tools default to ASK_USER
|
||||||
|
// 50: Auto-accept read-only tools
|
||||||
|
// 85: MCP servers allowed list
|
||||||
|
// 90: MCP servers with trust=true
|
||||||
|
// 100: Explicitly allowed individual tools
|
||||||
|
// 195: Explicitly excluded MCP servers
|
||||||
|
// 200: Explicitly excluded individual tools (highest priority)
|
||||||
|
|
||||||
|
// MCP servers that are explicitly allowed in settings.mcp.allowed
|
||||||
|
// Priority: 85 (lower than trusted servers)
|
||||||
|
if (settings.mcp?.allowed) {
|
||||||
|
for (const serverName of settings.mcp.allowed) {
|
||||||
|
rules.push({
|
||||||
|
toolName: `${serverName}__*`,
|
||||||
|
decision: PolicyDecision.ALLOW,
|
||||||
|
priority: 85,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCP servers that are trusted in the settings.
|
||||||
|
// Priority: 90 (higher than general allowed servers but lower than explicit tool allows)
|
||||||
|
if (settings.mcpServers) {
|
||||||
|
for (const [serverName, serverConfig] of Object.entries(
|
||||||
|
settings.mcpServers,
|
||||||
|
)) {
|
||||||
|
if (serverConfig.trust) {
|
||||||
|
// Trust all tools from this MCP server
|
||||||
|
// Using pattern matching for MCP tool names which are formatted as "serverName__toolName"
|
||||||
|
rules.push({
|
||||||
|
toolName: `${serverName}__*`,
|
||||||
|
decision: PolicyDecision.ALLOW,
|
||||||
|
priority: 90,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools that are explicitly allowed in the settings.
|
||||||
|
// Priority: 100
|
||||||
|
if (settings.tools?.allowed) {
|
||||||
|
for (const tool of settings.tools.allowed) {
|
||||||
|
rules.push({
|
||||||
|
toolName: tool,
|
||||||
|
decision: PolicyDecision.ALLOW,
|
||||||
|
priority: 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tools that are explicitly excluded in the settings.
|
||||||
|
// Priority: 200
|
||||||
|
if (settings.tools?.exclude) {
|
||||||
|
for (const tool of settings.tools.exclude) {
|
||||||
|
rules.push({
|
||||||
|
toolName: tool,
|
||||||
|
decision: PolicyDecision.DENY,
|
||||||
|
priority: 200,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MCP servers that are explicitly excluded in settings.mcp.excluded
|
||||||
|
// Priority: 195 (high priority to block servers)
|
||||||
|
if (settings.mcp?.excluded) {
|
||||||
|
for (const serverName of settings.mcp.excluded) {
|
||||||
|
rules.push({
|
||||||
|
toolName: `${serverName}__*`,
|
||||||
|
decision: PolicyDecision.DENY,
|
||||||
|
priority: 195,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If auto-accept is enabled, allow all read-only tools.
|
||||||
|
// Priority: 50
|
||||||
|
if (settings.tools?.autoAccept) {
|
||||||
|
for (const tool of READ_ONLY_TOOLS) {
|
||||||
|
rules.push({
|
||||||
|
toolName: tool,
|
||||||
|
decision: PolicyDecision.ALLOW,
|
||||||
|
priority: 50,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only add write tool rules if not in YOLO mode
|
||||||
|
// In YOLO mode, the wildcard ALLOW rule handles everything
|
||||||
|
if (approvalMode !== ApprovalMode.YOLO) {
|
||||||
|
for (const tool of WRITE_TOOLS) {
|
||||||
|
rules.push({
|
||||||
|
toolName: tool,
|
||||||
|
decision: PolicyDecision.ASK_USER,
|
||||||
|
priority: 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (approvalMode === ApprovalMode.YOLO) {
|
||||||
|
rules.push({
|
||||||
|
decision: PolicyDecision.ALLOW,
|
||||||
|
priority: 0, // Lowest priority - catches everything not explicitly configured
|
||||||
|
});
|
||||||
|
} else if (approvalMode === ApprovalMode.AUTO_EDIT) {
|
||||||
|
rules.push({
|
||||||
|
toolName: EditTool.Name,
|
||||||
|
decision: PolicyDecision.ALLOW,
|
||||||
|
priority: 15, // Higher than write tools (10) to override ASK_USER
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
rules,
|
||||||
|
defaultDecision: PolicyDecision.ASK_USER,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
export * from './config/config.js';
|
export * from './config/config.js';
|
||||||
export * from './output/types.js';
|
export * from './output/types.js';
|
||||||
export * from './output/json-formatter.js';
|
export * from './output/json-formatter.js';
|
||||||
|
export * from './policy/types.js';
|
||||||
|
export * from './policy/policy-engine.js';
|
||||||
|
|
||||||
// Export Core Logic
|
// Export Core Logic
|
||||||
export * from './core/client.js';
|
export * from './core/client.js';
|
||||||
|
|||||||
@@ -217,6 +217,75 @@ describe('PolicyEngine', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MCP server wildcard patterns', () => {
|
||||||
|
it('should match MCP server wildcard patterns', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{
|
||||||
|
toolName: 'my-server__*',
|
||||||
|
decision: PolicyDecision.ALLOW,
|
||||||
|
priority: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolName: 'blocked-server__*',
|
||||||
|
decision: PolicyDecision.DENY,
|
||||||
|
priority: 20,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
// Should match my-server tools
|
||||||
|
expect(engine.check({ name: 'my-server__tool1' })).toBe(
|
||||||
|
PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'my-server__another_tool' })).toBe(
|
||||||
|
PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should match blocked-server tools
|
||||||
|
expect(engine.check({ name: 'blocked-server__tool1' })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'blocked-server__dangerous' })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should not match other patterns
|
||||||
|
expect(engine.check({ name: 'other-server__tool' })).toBe(
|
||||||
|
PolicyDecision.ASK_USER,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'my-server-tool' })).toBe(
|
||||||
|
PolicyDecision.ASK_USER,
|
||||||
|
); // No __ separator
|
||||||
|
expect(engine.check({ name: 'my-server' })).toBe(PolicyDecision.ASK_USER); // No tool name
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prioritize specific tool rules over server wildcards', () => {
|
||||||
|
const rules: PolicyRule[] = [
|
||||||
|
{
|
||||||
|
toolName: 'my-server__*',
|
||||||
|
decision: PolicyDecision.ALLOW,
|
||||||
|
priority: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolName: 'my-server__dangerous-tool',
|
||||||
|
decision: PolicyDecision.DENY,
|
||||||
|
priority: 20,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
engine = new PolicyEngine({ rules });
|
||||||
|
|
||||||
|
// Specific tool deny should override server allow
|
||||||
|
expect(engine.check({ name: 'my-server__dangerous-tool' })).toBe(
|
||||||
|
PolicyDecision.DENY,
|
||||||
|
);
|
||||||
|
expect(engine.check({ name: 'my-server__safe-tool' })).toBe(
|
||||||
|
PolicyDecision.ALLOW,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('complex scenarios', () => {
|
describe('complex scenarios', () => {
|
||||||
it('should handle multiple matching rules with different priorities', () => {
|
it('should handle multiple matching rules with different priorities', () => {
|
||||||
const rules: PolicyRule[] = [
|
const rules: PolicyRule[] = [
|
||||||
|
|||||||
@@ -18,8 +18,16 @@ function ruleMatches(
|
|||||||
stringifiedArgs: string | undefined,
|
stringifiedArgs: string | undefined,
|
||||||
): boolean {
|
): boolean {
|
||||||
// Check tool name if specified
|
// Check tool name if specified
|
||||||
if (rule.toolName && toolCall.name !== rule.toolName) {
|
if (rule.toolName) {
|
||||||
return false;
|
// Support wildcard patterns: "serverName__*" matches "serverName__anyTool"
|
||||||
|
if (rule.toolName.endsWith('__*')) {
|
||||||
|
const prefix = rule.toolName.slice(0, -3); // Remove "__*"
|
||||||
|
if (!toolCall.name || !toolCall.name.startsWith(prefix + '__')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} else if (toolCall.name !== rule.toolName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check args pattern if specified
|
// Check args pattern if specified
|
||||||
|
|||||||
Reference in New Issue
Block a user