diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index bd7f7f5a9e..f56aec679e 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -43,6 +43,7 @@ import { resolvePath } from '../utils/resolvePath.js'; import { appEvents } from '../utils/events.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; +import { createPolicyEngineConfig } from './policy.js'; // Simple console logger for now - replace with actual logger if available const logger = { @@ -489,6 +490,8 @@ export async function loadCliConfig( approvalMode = ApprovalMode.DEFAULT; } + const policyEngineConfig = createPolicyEngineConfig(settings, approvalMode); + const interactive = !!argv.promptInteractive || (process.stdin.isTTY && question.length === 0); // In non-interactive mode, exclude tools that require a prompt. @@ -574,6 +577,7 @@ export async function loadCliConfig( fullContext: argv.allFiles || false, coreTools: settings.tools?.core || undefined, allowedTools: argv.allowedTools || settings.tools?.allowed || undefined, + policyEngineConfig, excludeTools, toolDiscoveryCommand: settings.tools?.discoveryCommand, toolCallCommand: settings.tools?.callCommand, diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts new file mode 100644 index 0000000000..3b19121d5f --- /dev/null +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -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); + } + }); + }); +}); diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts new file mode 100644 index 0000000000..38537852c4 --- /dev/null +++ b/packages/cli/src/config/policy.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts new file mode 100644 index 0000000000..9ef676460e --- /dev/null +++ b/packages/cli/src/config/policy.ts @@ -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, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4a640f3f2d..1d9dc71668 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,8 @@ export * from './config/config.js'; export * from './output/types.js'; export * from './output/json-formatter.js'; +export * from './policy/types.js'; +export * from './policy/policy-engine.js'; // Export Core Logic export * from './core/client.js'; diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts index 51f222b2e4..03caf2524e 100644 --- a/packages/core/src/policy/policy-engine.test.ts +++ b/packages/core/src/policy/policy-engine.test.ts @@ -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', () => { it('should handle multiple matching rules with different priorities', () => { const rules: PolicyRule[] = [ diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts index e1006ffdef..cf4388c09d 100644 --- a/packages/core/src/policy/policy-engine.ts +++ b/packages/core/src/policy/policy-engine.ts @@ -18,8 +18,16 @@ function ruleMatches( stringifiedArgs: string | undefined, ): boolean { // Check tool name if specified - if (rule.toolName && toolCall.name !== rule.toolName) { - return false; + if (rule.toolName) { + // 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