diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 76ee5e8a21..2a102f78bc 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -507,7 +507,31 @@ export async function loadCliConfig( throw err; } - const policyEngineConfig = createPolicyEngineConfig(settings, approvalMode); + const policyEngineConfig = await createPolicyEngineConfig( + settings, + approvalMode, + ); + + // Debug: Log the merged policy configuration + // Only log when message bus integration is enabled (when policies are active) + const enableMessageBusIntegration = + settings.tools?.enableMessageBusIntegration ?? false; + if (enableMessageBusIntegration) { + debugLogger.debug('=== Policy Engine Configuration ==='); + debugLogger.debug( + `Default decision: ${policyEngineConfig.defaultDecision}`, + ); + debugLogger.debug(`Total rules: ${policyEngineConfig.rules?.length || 0}`); + if (policyEngineConfig.rules && policyEngineConfig.rules.length > 0) { + debugLogger.debug('Rules (sorted by priority):'); + policyEngineConfig.rules.forEach((rule, index) => { + debugLogger.debug( + ` [${index}] toolName: ${rule.toolName || '*'}, decision: ${rule.decision}, priority: ${rule.priority}, argsPattern: ${rule.argsPattern ? rule.argsPattern.source : 'none'}`, + ); + }); + } + debugLogger.debug('==================================='); + } const allowedTools = argv.allowedTools || settings.tools?.allowed || []; const allowedToolsSet = new Set(allowedTools); @@ -672,8 +696,7 @@ export async function loadCliConfig( format: (argv.outputFormat ?? settings.output?.format) as OutputFormat, }, useModelRouter, - enableMessageBusIntegration: - settings.tools?.enableMessageBusIntegration ?? false, + enableMessageBusIntegration, codebaseInvestigatorSettings: settings.experimental?.codebaseInvestigatorSettings, fakeResponses: argv.fakeResponses, diff --git a/packages/cli/src/config/policies/read-only.toml b/packages/cli/src/config/policies/read-only.toml new file mode 100644 index 0000000000..0c36faf003 --- /dev/null +++ b/packages/cli/src/config/policies/read-only.toml @@ -0,0 +1,56 @@ +# 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 bands (tiers): +# - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) +# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# +# This ensures Admin > User > Default hierarchy is always preserved, +# while allowing user-specified priorities to work within each tier. +# +# Settings-based and dynamic rules (all in user tier 2.x): +# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 2.9: MCP servers excluded list (security: persistent server blocks) +# 2.4: Command line flag --exclude-tools (explicit temporary blocks) +# 2.3: Command line flag --allowed-tools (explicit temporary allows) +# 2.2: MCP servers with trust=true (persistent trusted servers) +# 2.1: MCP servers allowed list (persistent general server allows) +# +# TOML policy priorities (before transformation): +# 10: Write tools default to ASK_USER (becomes 1.010 in default tier) +# 15: Auto-edit tool override (becomes 1.015 in default tier) +# 50: Read-only tools (becomes 1.050 in default tier) +# 999: YOLO mode allow-all (becomes 1.999 in default tier) + +[[rule]] +toolName = "glob" +decision = "allow" +priority = 50 + +[[rule]] +toolName = "search_file_content" +decision = "allow" +priority = 50 + +[[rule]] +toolName = "list_directory" +decision = "allow" +priority = 50 + +[[rule]] +toolName = "read_file" +decision = "allow" +priority = 50 + +[[rule]] +toolName = "read_many_files" +decision = "allow" +priority = 50 + +[[rule]] +toolName = "google_web_search" +decision = "allow" +priority = 50 diff --git a/packages/cli/src/config/policies/write.toml b/packages/cli/src/config/policies/write.toml new file mode 100644 index 0000000000..8e4c1ae70e --- /dev/null +++ b/packages/cli/src/config/policies/write.toml @@ -0,0 +1,63 @@ +# 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 bands (tiers): +# - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) +# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# +# This ensures Admin > User > Default hierarchy is always preserved, +# while allowing user-specified priorities to work within each tier. +# +# Settings-based and dynamic rules (all in user tier 2.x): +# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 2.9: MCP servers excluded list (security: persistent server blocks) +# 2.4: Command line flag --exclude-tools (explicit temporary blocks) +# 2.3: Command line flag --allowed-tools (explicit temporary allows) +# 2.2: MCP servers with trust=true (persistent trusted servers) +# 2.1: MCP servers allowed list (persistent general server allows) +# +# TOML policy priorities (before transformation): +# 10: Write tools default to ASK_USER (becomes 1.010 in default tier) +# 15: Auto-edit tool override (becomes 1.015 in default tier) +# 50: Read-only tools (becomes 1.050 in default tier) +# 999: YOLO mode allow-all (becomes 1.999 in default tier) + +[[rule]] +toolName = "replace" +decision = "ask_user" +priority = 10 + +[[rule]] +toolName = "replace" +decision = "allow" +priority = 15 +modes = ["autoEdit"] + +[[rule]] +toolName = "save_memory" +decision = "ask_user" +priority = 10 + +[[rule]] +toolName = "run_shell_command" +decision = "ask_user" +priority = 10 + +[[rule]] +toolName = "write_file" +decision = "ask_user" +priority = 10 + +[[rule]] +toolName = "write_file" +decision = "allow" +priority = 15 +modes = ["autoEdit"] + +[[rule]] +toolName = "web_fetch" +decision = "ask_user" +priority = 10 diff --git a/packages/cli/src/config/policies/yolo.toml b/packages/cli/src/config/policies/yolo.toml new file mode 100644 index 0000000000..0c5f9e9221 --- /dev/null +++ b/packages/cli/src/config/policies/yolo.toml @@ -0,0 +1,31 @@ +# 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 bands (tiers): +# - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) +# - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) +# - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) +# +# This ensures Admin > User > Default hierarchy is always preserved, +# while allowing user-specified priorities to work within each tier. +# +# Settings-based and dynamic rules (all in user tier 2.x): +# 2.95: Tools that the user has selected as "Always Allow" in the interactive UI +# 2.9: MCP servers excluded list (security: persistent server blocks) +# 2.4: Command line flag --exclude-tools (explicit temporary blocks) +# 2.3: Command line flag --allowed-tools (explicit temporary allows) +# 2.2: MCP servers with trust=true (persistent trusted servers) +# 2.1: MCP servers allowed list (persistent general server allows) +# +# TOML policy priorities (before transformation): +# 10: Write tools default to ASK_USER (becomes 1.010 in default tier) +# 15: Auto-edit tool override (becomes 1.015 in default tier) +# 50: Read-only tools (becomes 1.050 in default tier) +# 999: YOLO mode allow-all (becomes 1.999 in default tier) + +[[rule]] +decision = "allow" +priority = 999 +modes = ["yolo"] diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 3b19121d5f..9b8457bc33 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -15,7 +15,7 @@ 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', () => { + it('should create a working PolicyEngine from basic settings', async () => { const settings: Settings = { tools: { allowed: ['run_shell_command'], @@ -23,7 +23,10 @@ describe('Policy Engine Integration Tests', () => { }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const engine = new PolicyEngine(config); // Allowed tool should be allowed @@ -43,7 +46,7 @@ describe('Policy Engine Integration Tests', () => { ); }); - it('should handle MCP server wildcard patterns correctly', () => { + it('should handle MCP server wildcard patterns correctly', async () => { const settings: Settings = { mcp: { allowed: ['allowed-server'], @@ -58,7 +61,10 @@ describe('Policy Engine Integration Tests', () => { }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const engine = new PolicyEngine(config); // Tools from allowed server should be allowed @@ -91,7 +97,7 @@ describe('Policy Engine Integration Tests', () => { ); }); - it('should correctly prioritize specific tool rules over MCP server wildcards', () => { + it('should correctly prioritize specific tool excludes over MCP server wildcards', async () => { const settings: Settings = { mcp: { allowed: ['my-server'], @@ -101,19 +107,23 @@ describe('Policy Engine Integration Tests', () => { }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const engine = new PolicyEngine(config); - // Server is allowed, but specific tool is excluded + // MCP server allowed (priority 2.1) provides general allow for server expect(engine.check({ name: 'my-server__safe-tool' })).toBe( PolicyDecision.ALLOW, ); + // But specific tool exclude (priority 2.4) wins over server allow expect(engine.check({ name: 'my-server__dangerous-tool' })).toBe( PolicyDecision.DENY, ); }); - it('should handle complex mixed configurations', () => { + it('should handle complex mixed configurations', async () => { const settings: Settings = { tools: { autoAccept: true, // Allows read-only tools @@ -133,7 +143,10 @@ describe('Policy Engine Integration Tests', () => { }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const engine = new PolicyEngine(config); // Read-only tools should be allowed (autoAccept) @@ -171,14 +184,17 @@ describe('Policy Engine Integration Tests', () => { ); }); - it('should handle YOLO mode correctly', () => { + it('should handle YOLO mode correctly', async () => { const settings: Settings = { tools: { exclude: ['dangerous-tool'], // Even in YOLO, excludes should be respected }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.YOLO); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.YOLO, + ); const engine = new PolicyEngine(config); // Most tools should be allowed in YOLO mode @@ -194,25 +210,26 @@ describe('Policy Engine Integration Tests', () => { ); }); - it('should handle AUTO_EDIT mode correctly', () => { + it('should handle AUTO_EDIT mode correctly', async () => { const settings: Settings = {}; - const config = createPolicyEngineConfig(settings, ApprovalMode.AUTO_EDIT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.AUTO_EDIT, + ); const engine = new PolicyEngine(config); - // Edit tool should be allowed (EditTool.Name = 'replace') + // Edit tools should be allowed in AUTO_EDIT mode expect(engine.check({ name: 'replace' })).toBe(PolicyDecision.ALLOW); + expect(engine.check({ name: 'write_file' })).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', () => { + it('should verify priority ordering works correctly in practice', async () => { const settings: Settings = { tools: { autoAccept: true, // Priority 50 @@ -232,7 +249,10 @@ describe('Policy Engine Integration Tests', () => { }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const engine = new PolicyEngine(config); // Test that priorities are applied correctly @@ -240,28 +260,29 @@ describe('Policy Engine Integration Tests', () => { // Find rules and verify their priorities const blockedToolRule = rules.find((r) => r.toolName === 'blocked-tool'); - expect(blockedToolRule?.priority).toBe(200); + expect(blockedToolRule?.priority).toBe(2.4); // Command line exclude const blockedServerRule = rules.find( (r) => r.toolName === 'blocked-server__*', ); - expect(blockedServerRule?.priority).toBe(195); + expect(blockedServerRule?.priority).toBe(2.9); // MCP server exclude const specificToolRule = rules.find( (r) => r.toolName === 'specific-tool', ); - expect(specificToolRule?.priority).toBe(100); + expect(specificToolRule?.priority).toBe(2.3); // Command line allow const trustedServerRule = rules.find( (r) => r.toolName === 'trusted-server__*', ); - expect(trustedServerRule?.priority).toBe(90); + expect(trustedServerRule?.priority).toBe(2.2); // MCP trusted server const mcpServerRule = rules.find((r) => r.toolName === 'mcp-server__*'); - expect(mcpServerRule?.priority).toBe(85); + expect(mcpServerRule?.priority).toBe(2.1); // MCP allowed server const readOnlyToolRule = rules.find((r) => r.toolName === 'glob'); - expect(readOnlyToolRule?.priority).toBe(50); + // Priority 50 in default tier → 1.05 + expect(readOnlyToolRule?.priority).toBeCloseTo(1.05, 5); // Verify the engine applies these priorities correctly expect(engine.check({ name: 'blocked-tool' })).toBe(PolicyDecision.DENY); @@ -280,7 +301,7 @@ describe('Policy Engine Integration Tests', () => { expect(engine.check({ name: 'glob' })).toBe(PolicyDecision.ALLOW); }); - it('should handle edge case: MCP server with both trust and exclusion', () => { + it('should handle edge case: MCP server with both trust and exclusion', async () => { const settings: Settings = { mcpServers: { 'conflicted-server': { @@ -294,7 +315,10 @@ describe('Policy Engine Integration Tests', () => { }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const engine = new PolicyEngine(config); // Exclusion (195) should win over trust (90) @@ -303,7 +327,7 @@ describe('Policy Engine Integration Tests', () => { ); }); - it('should handle edge case: specific tool allowed but server excluded', () => { + it('should handle edge case: specific tool allowed but server excluded', async () => { const settings: Settings = { mcp: { excluded: ['my-server'], // Priority 195 - DENY @@ -313,7 +337,10 @@ describe('Policy Engine Integration Tests', () => { }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const engine = new PolicyEngine(config); // Server exclusion (195) wins over specific tool allow (100) @@ -326,10 +353,13 @@ describe('Policy Engine Integration Tests', () => { ); }); - it('should verify non-interactive mode transformation', () => { + it('should verify non-interactive mode transformation', async () => { const settings: Settings = {}; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); // Enable non-interactive mode const engineConfig = { ...config, nonInteractive: true }; const engine = new PolicyEngine(engineConfig); @@ -341,10 +371,13 @@ describe('Policy Engine Integration Tests', () => { ); }); - it('should handle empty settings gracefully', () => { + it('should handle empty settings gracefully', async () => { const settings: Settings = {}; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const engine = new PolicyEngine(config); // Should have default rules for write tools @@ -357,7 +390,7 @@ describe('Policy Engine Integration Tests', () => { expect(engine.check({ name: 'unknown' })).toBe(PolicyDecision.ASK_USER); }); - it('should verify rules are created with correct priorities', () => { + it('should verify rules are created with correct priorities', async () => { const settings: Settings = { tools: { autoAccept: true, @@ -370,24 +403,28 @@ describe('Policy Engine Integration Tests', () => { }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await 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 + expect(tool3Rule?.priority).toBe(2.4); // Excluded tools (user tier) const server2Rule = rules.find((r) => r.toolName === 'server2__*'); - expect(server2Rule?.priority).toBe(195); // Excluded servers + expect(server2Rule?.priority).toBe(2.9); // Excluded servers (user tier) const tool1Rule = rules.find((r) => r.toolName === 'tool1'); - expect(tool1Rule?.priority).toBe(100); // Allowed tools + expect(tool1Rule?.priority).toBe(2.3); // Allowed tools (user tier) const server1Rule = rules.find((r) => r.toolName === 'server1__*'); - expect(server1Rule?.priority).toBe(85); // Allowed servers + expect(server1Rule?.priority).toBe(2.1); // Allowed servers (user tier) const globRule = rules.find((r) => r.toolName === 'glob'); - expect(globRule?.priority).toBe(50); // Auto-accept read-only + // Priority 50 in default tier → 1.05 + expect(globRule?.priority).toBeCloseTo(1.05, 5); // Auto-accept read-only // The PolicyEngine will sort these by priority when it's created const engine = new PolicyEngine(config); diff --git a/packages/cli/src/config/policy-toml-loader.test.ts b/packages/cli/src/config/policy-toml-loader.test.ts new file mode 100644 index 0000000000..e05996b16c --- /dev/null +++ b/packages/cli/src/config/policy-toml-loader.test.ts @@ -0,0 +1,982 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { ApprovalMode, PolicyDecision } from '@google/gemini-cli-core'; +import type { Dirent } from 'node:fs'; +import nodePath from 'node:path'; + +describe('policy-toml-loader', () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.doUnmock('node:fs/promises'); + }); + + describe('loadPoliciesFromToml', () => { + it('should load and parse a simple policy file', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'test.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'test.toml')) + ) { + return ` +[[rule]] +toolName = "glob" +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(1); + expect(result.rules[0]).toEqual({ + toolName: 'glob', + decision: PolicyDecision.ALLOW, + priority: 1.1, // tier 1 + 100/1000 + }); + expect(result.errors).toHaveLength(0); + }); + + it('should expand commandPrefix array to multiple rules', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'shell.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'shell.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +commandPrefix = ["git status", "git log"] +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 2; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(2); + expect(result.rules[0].toolName).toBe('run_shell_command'); + expect(result.rules[1].toolName).toBe('run_shell_command'); + expect( + result.rules[0].argsPattern?.test('{"command":"git status"}'), + ).toBe(true); + expect(result.rules[1].argsPattern?.test('{"command":"git log"}')).toBe( + true, + ); + expect(result.errors).toHaveLength(0); + }); + + it('should transform commandRegex to argsPattern', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'shell.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'shell.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +commandRegex = "git (status|log).*" +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 2; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(1); + expect( + result.rules[0].argsPattern?.test('{"command":"git status"}'), + ).toBe(true); + expect( + result.rules[0].argsPattern?.test('{"command":"git log --all"}'), + ).toBe(true); + expect( + result.rules[0].argsPattern?.test('{"command":"git branch"}'), + ).toBe(false); + expect(result.errors).toHaveLength(0); + }); + + it('should expand toolName array', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'tools.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'tools.toml')) + ) { + return ` +[[rule]] +toolName = ["glob", "grep", "read"] +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(3); + expect(result.rules.map((r) => r.toolName)).toEqual([ + 'glob', + 'grep', + 'read', + ]); + expect(result.errors).toHaveLength(0); + }); + + it('should transform mcpName to composite toolName', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'mcp.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'mcp.toml')) + ) { + return ` +[[rule]] +mcpName = "google-workspace" +toolName = ["calendar.list", "calendar.get"] +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 2; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(2); + expect(result.rules[0].toolName).toBe('google-workspace__calendar.list'); + expect(result.rules[1].toolName).toBe('google-workspace__calendar.get'); + expect(result.errors).toHaveLength(0); + }); + + it('should filter rules by mode', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'modes.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'modes.toml')) + ) { + return ` +[[rule]] +toolName = "glob" +decision = "allow" +priority = 100 +modes = ["default", "yolo"] + +[[rule]] +toolName = "grep" +decision = "allow" +priority = 100 +modes = ["yolo"] +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + // Only the first rule should be included (modes includes "default") + expect(result.rules).toHaveLength(1); + expect(result.rules[0].toolName).toBe('glob'); + expect(result.errors).toHaveLength(0); + }); + + it('should handle TOML parse errors', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'invalid.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) + ) { + return ` +[[rule] +toolName = "glob" +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].errorType).toBe('toml_parse'); + expect(result.errors[0].fileName).toBe('invalid.toml'); + }); + + it('should handle schema validation errors', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'invalid.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) + ) { + return ` +[[rule]] +toolName = "glob" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].errorType).toBe('schema_validation'); + expect(result.errors[0].details).toContain('decision'); + }); + + it('should reject commandPrefix without run_shell_command', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'invalid.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) + ) { + return ` +[[rule]] +toolName = "glob" +commandPrefix = "git status" +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].errorType).toBe('rule_validation'); + expect(result.errors[0].details).toContain('run_shell_command'); + }); + + it('should reject commandPrefix + argsPattern combination', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'invalid.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +commandPrefix = "git status" +argsPattern = "test" +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0].errorType).toBe('rule_validation'); + expect(result.errors[0].details).toContain('mutually exclusive'); + }); + + it('should handle invalid regex patterns', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'invalid.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +commandRegex = "git (status|branch" +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].errorType).toBe('regex_compilation'); + expect(result.errors[0].details).toContain('git (status|branch'); + }); + + it('should escape regex special characters in commandPrefix', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'shell.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'shell.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +commandPrefix = "git log *.txt" +decision = "allow" +priority = 100 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(1); + // Should match literal asterisk, not wildcard + expect( + result.rules[0].argsPattern?.test('{"command":"git log *.txt"}'), + ).toBe(true); + expect( + result.rules[0].argsPattern?.test('{"command":"git log a.txt"}'), + ).toBe(false); + expect(result.errors).toHaveLength(0); + }); + + it('should handle non-existent directory gracefully', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn(async (_path: string): Promise => { + const error: NodeJS.ErrnoException = new Error('ENOENT'); + error.code = 'ENOENT'; + throw error; + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readdir: mockReaddir }, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/non-existent'], + getPolicyTier, + ); + + // Should not error for missing directories + expect(result.rules).toHaveLength(0); + expect(result.errors).toHaveLength(0); + }); + + it('should reject priority >= 1000 with helpful error message', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'invalid.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) + ) { + return ` +[[rule]] +toolName = "glob" +decision = "allow" +priority = 1000 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].errorType).toBe('schema_validation'); + expect(result.errors[0].details).toContain('priority'); + expect(result.errors[0].details).toContain('tier overflow'); + expect(result.errors[0].details).toContain( + 'Priorities >= 1000 would jump to the next tier', + ); + expect(result.errors[0].details).toContain('<= 999'); + }); + + it('should reject negative priority with helpful error message', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string, + _options?: { withFileTypes: boolean }, + ): Promise => { + if (nodePath.normalize(path) === nodePath.normalize('/policies')) { + return [ + { + name: 'invalid.toml', + isFile: () => true, + isDirectory: () => false, + } as Dirent, + ]; + } + return []; + }, + ); + + const mockReadFile = vi.fn(async (path: string): Promise => { + if ( + nodePath.normalize(path) === + nodePath.normalize(nodePath.join('/policies', 'invalid.toml')) + ) { + return ` +[[rule]] +toolName = "glob" +decision = "allow" +priority = -1 +`; + } + throw new Error('File not found'); + }); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + const { loadPoliciesFromToml: load } = await import( + './policy-toml-loader.js' + ); + + const getPolicyTier = (_dir: string) => 1; + const result = await load( + ApprovalMode.DEFAULT, + ['/policies'], + getPolicyTier, + ); + + expect(result.rules).toHaveLength(0); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].errorType).toBe('schema_validation'); + expect(result.errors[0].details).toContain('priority'); + expect(result.errors[0].details).toContain('>= 0'); + expect(result.errors[0].details).toContain('must be >= 0'); + }); + }); +}); diff --git a/packages/cli/src/config/policy-toml-loader.ts b/packages/cli/src/config/policy-toml-loader.ts new file mode 100644 index 0000000000..fb5a7d1253 --- /dev/null +++ b/packages/cli/src/config/policy-toml-loader.ts @@ -0,0 +1,394 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type PolicyRule, + PolicyDecision, + type ApprovalMode, +} from '@google/gemini-cli-core'; +import fs from 'node:fs/promises'; +import path from 'node:path'; +import toml from '@iarna/toml'; +import { z, type ZodError } from 'zod'; + +/** + * Schema for a single policy rule in the TOML file (before transformation). + */ +const PolicyRuleSchema = z.object({ + toolName: z.union([z.string(), z.array(z.string())]).optional(), + mcpName: z.string().optional(), + argsPattern: z.string().optional(), + commandPrefix: z.union([z.string(), z.array(z.string())]).optional(), + commandRegex: z.string().optional(), + decision: z.nativeEnum(PolicyDecision), + // Priority must be in range [0, 999] to prevent tier overflow. + // With tier transformation (tier + priority/1000), this ensures: + // - Tier 1 (default): range [1.000, 1.999] + // - Tier 2 (user): range [2.000, 2.999] + // - Tier 3 (admin): range [3.000, 3.999] + priority: z + .number({ + required_error: 'priority is required', + invalid_type_error: 'priority must be a number', + }) + .int({ message: 'priority must be an integer' }) + .min(0, { message: 'priority must be >= 0' }) + .max(999, { + message: + 'priority must be <= 999 to prevent tier overflow. Priorities >= 1000 would jump to the next tier.', + }), + modes: z.array(z.string()).optional(), +}); + +/** + * Schema for the entire policy TOML file. + */ +const PolicyFileSchema = z.object({ + rule: z.array(PolicyRuleSchema), +}); + +/** + * Type for a raw policy rule from TOML (before transformation). + */ +type PolicyRuleToml = z.infer; + +/** + * Types of errors that can occur while loading policy files. + */ +export type PolicyFileErrorType = + | 'file_read' + | 'toml_parse' + | 'schema_validation' + | 'rule_validation' + | 'regex_compilation'; + +/** + * Detailed error information for policy file loading failures. + */ +export interface PolicyFileError { + filePath: string; + fileName: string; + tier: 'default' | 'user' | 'admin'; + ruleIndex?: number; + errorType: PolicyFileErrorType; + message: string; + details?: string; + suggestion?: string; +} + +/** + * Result of loading policies from TOML files. + */ +export interface PolicyLoadResult { + rules: PolicyRule[]; + errors: PolicyFileError[]; +} + +/** + * Escapes special regex characters in a string for use in a regex pattern. + * This is used for commandPrefix to ensure literal string matching. + * + * @param str The string to escape + * @returns The escaped string safe for use in a regex + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Converts a tier number to a human-readable tier name. + */ +function getTierName(tier: number): 'default' | 'user' | 'admin' { + if (tier === 1) return 'default'; + if (tier === 2) return 'user'; + if (tier === 3) return 'admin'; + return 'default'; +} + +/** + * Formats a Zod validation error into a readable error message. + */ +function formatSchemaError(error: ZodError, ruleIndex: number): string { + const issues = error.issues + .map((issue) => { + const path = issue.path.join('.'); + return ` - Field "${path}": ${issue.message}`; + }) + .join('\n'); + return `Invalid policy rule (rule #${ruleIndex + 1}):\n${issues}`; +} + +/** + * Validates shell command convenience syntax rules. + * Returns an error message if invalid, or null if valid. + */ +function validateShellCommandSyntax( + rule: PolicyRuleToml, + ruleIndex: number, +): string | null { + const hasCommandPrefix = rule.commandPrefix !== undefined; + const hasCommandRegex = rule.commandRegex !== undefined; + const hasArgsPattern = rule.argsPattern !== undefined; + + if (hasCommandPrefix || hasCommandRegex) { + // Must have exactly toolName = "run_shell_command" + if (rule.toolName !== 'run_shell_command' || Array.isArray(rule.toolName)) { + return ( + `Rule #${ruleIndex + 1}: commandPrefix and commandRegex can only be used with toolName = "run_shell_command"\n` + + ` Found: toolName = ${JSON.stringify(rule.toolName)}\n` + + ` Fix: Set toolName = "run_shell_command" (not an array)` + ); + } + + // Can't combine with argsPattern + if (hasArgsPattern) { + return ( + `Rule #${ruleIndex + 1}: cannot use both commandPrefix/commandRegex and argsPattern\n` + + ` These fields are mutually exclusive\n` + + ` Fix: Use either commandPrefix/commandRegex OR argsPattern, not both` + ); + } + + // Can't use both commandPrefix and commandRegex + if (hasCommandPrefix && hasCommandRegex) { + return ( + `Rule #${ruleIndex + 1}: cannot use both commandPrefix and commandRegex\n` + + ` These fields are mutually exclusive\n` + + ` Fix: Use either commandPrefix OR commandRegex, not both` + ); + } + } + + return null; +} + +/** + * Transforms a priority number based on the policy tier. + * Formula: tier + priority/1000 + * + * @param priority The priority value from the TOML file + * @param tier The tier (1=default, 2=user, 3=admin) + * @returns The transformed priority + */ +function transformPriority(priority: number, tier: number): number { + return tier + priority / 1000; +} + +/** + * Loads and parses policies from TOML files in the specified directories. + * + * This function: + * 1. Scans directories for .toml files + * 2. Parses and validates each file + * 3. Transforms rules (commandPrefix, arrays, mcpName, priorities) + * 4. Filters rules by approval mode + * 5. Collects detailed error information for any failures + * + * @param approvalMode The current approval mode (for filtering rules by mode) + * @param policyDirs Array of directory paths to scan for policy files + * @param getPolicyTier Function to determine tier (1-3) for a directory + * @returns Object containing successfully parsed rules and any errors encountered + */ +export async function loadPoliciesFromToml( + approvalMode: ApprovalMode, + policyDirs: string[], + getPolicyTier: (dir: string) => number, +): Promise { + const rules: PolicyRule[] = []; + const errors: PolicyFileError[] = []; + + for (const dir of policyDirs) { + const tier = getPolicyTier(dir); + const tierName = getTierName(tier); + + // Scan directory for all .toml files + let filesToLoad: string[]; + try { + const dirEntries = await fs.readdir(dir, { withFileTypes: true }); + filesToLoad = dirEntries + .filter((entry) => entry.isFile() && entry.name.endsWith('.toml')) + .map((entry) => entry.name); + } catch (e) { + const error = e as NodeJS.ErrnoException; + if (error.code === 'ENOENT') { + // Directory doesn't exist, skip it (not an error) + continue; + } + errors.push({ + filePath: dir, + fileName: path.basename(dir), + tier: tierName, + errorType: 'file_read', + message: `Failed to read policy directory`, + details: error.message, + }); + continue; + } + + for (const file of filesToLoad) { + const filePath = path.join(dir, file); + + try { + // Read file + const fileContent = await fs.readFile(filePath, 'utf-8'); + + // Parse TOML + let parsed: unknown; + try { + parsed = toml.parse(fileContent); + } catch (e) { + const error = e as Error; + errors.push({ + filePath, + fileName: file, + tier: tierName, + errorType: 'toml_parse', + message: 'TOML parsing failed', + details: error.message, + suggestion: + 'Check for syntax errors like missing quotes, brackets, or commas', + }); + continue; + } + + // Validate schema + const validationResult = PolicyFileSchema.safeParse(parsed); + if (!validationResult.success) { + errors.push({ + filePath, + fileName: file, + tier: tierName, + errorType: 'schema_validation', + message: 'Schema validation failed', + details: formatSchemaError(validationResult.error, 0), + suggestion: + 'Ensure all required fields (decision, priority) are present with correct types', + }); + continue; + } + + // Validate shell command convenience syntax + for (let i = 0; i < validationResult.data.rule.length; i++) { + const rule = validationResult.data.rule[i]; + const validationError = validateShellCommandSyntax(rule, i); + if (validationError) { + errors.push({ + filePath, + fileName: file, + tier: tierName, + ruleIndex: i, + errorType: 'rule_validation', + message: 'Invalid shell command syntax', + details: validationError, + }); + // Continue to next rule, don't skip the entire file + } + } + + // Transform rules + const parsedRules: PolicyRule[] = validationResult.data.rule + .filter((rule) => { + // Filter by mode + if (!rule.modes || rule.modes.length === 0) { + return true; + } + return rule.modes.includes(approvalMode); + }) + .flatMap((rule) => { + // Transform commandPrefix/commandRegex to argsPattern + let effectiveArgsPattern = rule.argsPattern; + const commandPrefixes: string[] = []; + + if (rule.commandPrefix) { + const prefixes = Array.isArray(rule.commandPrefix) + ? rule.commandPrefix + : [rule.commandPrefix]; + commandPrefixes.push(...prefixes); + } else if (rule.commandRegex) { + effectiveArgsPattern = `"command":"${rule.commandRegex}`; + } + + // Expand command prefixes to multiple patterns + const argsPatterns: Array = + commandPrefixes.length > 0 + ? commandPrefixes.map( + (prefix) => `"command":"${escapeRegex(prefix)}`, + ) + : [effectiveArgsPattern]; + + // For each argsPattern, expand toolName arrays + return argsPatterns.flatMap((argsPattern) => { + const toolNames: Array = rule.toolName + ? Array.isArray(rule.toolName) + ? rule.toolName + : [rule.toolName] + : [undefined]; + + // Create a policy rule for each tool name + return toolNames.map((toolName) => { + // Transform mcpName field to composite toolName format + let effectiveToolName: string | undefined; + if (rule.mcpName && toolName) { + effectiveToolName = `${rule.mcpName}__${toolName}`; + } else if (rule.mcpName) { + effectiveToolName = `${rule.mcpName}__*`; + } else { + effectiveToolName = toolName; + } + + const policyRule: PolicyRule = { + toolName: effectiveToolName, + decision: rule.decision, + priority: transformPriority(rule.priority, tier), + }; + + // Compile regex pattern + if (argsPattern) { + try { + policyRule.argsPattern = new RegExp(argsPattern); + } catch (e) { + const error = e as Error; + errors.push({ + filePath, + fileName: file, + tier: tierName, + errorType: 'regex_compilation', + message: 'Invalid regex pattern', + details: `Pattern: ${argsPattern}\nError: ${error.message}`, + suggestion: + 'Check regex syntax for errors like unmatched brackets or invalid escape sequences', + }); + // Skip this rule if regex compilation fails + return null; + } + } + + return policyRule; + }); + }); + }) + .filter((rule): rule is PolicyRule => rule !== null); + + rules.push(...parsedRules); + } catch (e) { + const error = e as NodeJS.ErrnoException; + // Catch-all for unexpected errors + if (error.code !== 'ENOENT') { + errors.push({ + filePath, + fileName: file, + tier: tierName, + errorType: 'file_read', + message: 'Failed to read policy file', + details: error.message, + }); + } + } + } + } + + return { rules, errors }; +} diff --git a/packages/cli/src/config/policy.test.ts b/packages/cli/src/config/policy.test.ts index f6c442a9e6..8589165750 100644 --- a/packages/cli/src/config/policy.test.ts +++ b/packages/cli/src/config/policy.test.ts @@ -4,8 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect } from 'vitest'; -import { createPolicyEngineConfig } from './policy.js'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import nodePath from 'node:path'; + import type { Settings } from './settings.js'; import { ApprovalMode, @@ -13,129 +14,191 @@ import { WEB_FETCH_TOOL_NAME, } from '@google/gemini-cli-core'; +afterEach(() => { + vi.clearAllMocks(); +}); + describe('createPolicyEngineConfig', () => { - it('should return ASK_USER for write tools and ALLOW for read-only tools by default', () => { + it('should return ASK_USER for write tools and ALLOW for read-only tools by default', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + // Return empty array for user policies + return [] as unknown as Awaited>; + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readdir: mockReaddir }, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + const settings: Settings = {}; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); expect(config.defaultDecision).toBe(PolicyDecision.ASK_USER); // The order of the rules is not guaranteed, so we sort them by tool name. config.rules?.sort((a, b) => (a.toolName ?? '').localeCompare(b.toolName ?? ''), ); + // Default policies are transformed to tier 1: 1 + priority/1000 expect(config.rules).toEqual([ { toolName: 'glob', decision: PolicyDecision.ALLOW, - priority: 50, + priority: 1.05, // 1 + 50/1000 }, { toolName: 'google_web_search', decision: PolicyDecision.ALLOW, - priority: 50, + priority: 1.05, }, { toolName: 'list_directory', decision: PolicyDecision.ALLOW, - priority: 50, + priority: 1.05, }, { toolName: 'read_file', decision: PolicyDecision.ALLOW, - priority: 50, + priority: 1.05, }, { toolName: 'read_many_files', decision: PolicyDecision.ALLOW, - priority: 50, + priority: 1.05, }, { toolName: 'replace', decision: PolicyDecision.ASK_USER, - priority: 10, + priority: 1.01, // 1 + 10/1000 }, { toolName: 'run_shell_command', decision: PolicyDecision.ASK_USER, - priority: 10, + priority: 1.01, }, { toolName: 'save_memory', decision: PolicyDecision.ASK_USER, - priority: 10, + priority: 1.01, }, { toolName: 'search_file_content', decision: PolicyDecision.ALLOW, - priority: 50, + priority: 1.05, }, { toolName: 'web_fetch', decision: PolicyDecision.ASK_USER, - priority: 10, + priority: 1.01, }, { toolName: 'write_file', decision: PolicyDecision.ASK_USER, - priority: 10, + priority: 1.01, }, ]); + + vi.doUnmock('node:fs/promises'); }); - it('should allow tools in tools.allowed', () => { + it('should allow tools in tools.allowed', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { tools: { allowed: ['run_shell_command'] }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await 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); + expect(rule?.priority).toBeCloseTo(2.3, 5); // Command line allow }); - it('should deny tools in tools.exclude', () => { + it('should deny tools in tools.exclude', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { tools: { exclude: ['run_shell_command'] }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await 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); + expect(rule?.priority).toBeCloseTo(2.4, 5); // Command line exclude }); - it('should allow tools from allowed MCP servers', () => { + it('should allow tools from allowed MCP servers', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { mcp: { allowed: ['my-server'] }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await 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); + expect(rule?.priority).toBe(2.1); // MCP allowed server }); - it('should deny tools from excluded MCP servers', () => { + it('should deny tools from excluded MCP servers', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { mcp: { excluded: ['my-server'] }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await 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); + expect(rule?.priority).toBe(2.9); // MCP excluded server }); - it('should allow tools from trusted MCP servers', () => { + it('should allow tools from trusted MCP servers', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { mcpServers: { 'trusted-server': { @@ -150,7 +213,10 @@ describe('createPolicyEngineConfig', () => { }, }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const trustedRule = config.rules?.find( (r) => @@ -158,7 +224,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(trustedRule).toBeDefined(); - expect(trustedRule?.priority).toBe(90); + expect(trustedRule?.priority).toBe(2.2); // MCP trusted server // Untrusted server should not have an allow rule const untrustedRule = config.rules?.find( @@ -169,7 +235,8 @@ describe('createPolicyEngineConfig', () => { expect(untrustedRule).toBeUndefined(); }); - it('should handle multiple MCP server configurations together', () => { + it('should handle multiple MCP server configurations together', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { mcp: { allowed: ['allowed-server'], @@ -183,7 +250,10 @@ describe('createPolicyEngineConfig', () => { }, }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); // Check allowed server const allowedRule = config.rules?.find( @@ -192,7 +262,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(allowedRule).toBeDefined(); - expect(allowedRule?.priority).toBe(85); + expect(allowedRule?.priority).toBe(2.1); // MCP allowed server // Check trusted server const trustedRule = config.rules?.find( @@ -201,7 +271,7 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.ALLOW, ); expect(trustedRule).toBeDefined(); - expect(trustedRule?.priority).toBe(90); + expect(trustedRule?.priority).toBe(2.2); // MCP trusted server // Check excluded server const excludedRule = config.rules?.find( @@ -210,33 +280,45 @@ describe('createPolicyEngineConfig', () => { r.decision === PolicyDecision.DENY, ); expect(excludedRule).toBeDefined(); - expect(excludedRule?.priority).toBe(195); + expect(excludedRule?.priority).toBe(2.9); // MCP excluded server }); - it('should allow all tools in YOLO mode', () => { + it('should allow all tools in YOLO mode', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = {}; - const config = createPolicyEngineConfig(settings, ApprovalMode.YOLO); + const config = await createPolicyEngineConfig(settings, ApprovalMode.YOLO); const rule = config.rules?.find( - (r) => r.decision === PolicyDecision.ALLOW && r.priority === 0, + (r) => r.decision === PolicyDecision.ALLOW && !r.toolName, ); expect(rule).toBeDefined(); + // Priority 999 in default tier → 1.999 + expect(rule?.priority).toBeCloseTo(1.999, 5); }); - it('should allow edit tool in AUTO_EDIT mode', () => { + it('should allow edit tool in AUTO_EDIT mode', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = {}; - const config = createPolicyEngineConfig(settings, ApprovalMode.AUTO_EDIT); + const config = await 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); + // Priority 15 in default tier → 1.015 + expect(rule?.priority).toBeCloseTo(1.015, 5); }); - it('should prioritize exclude over allow', () => { + it('should prioritize exclude over allow', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { tools: { allowed: ['run_shell_command'], exclude: ['run_shell_command'] }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const denyRule = config.rules?.find( (r) => r.toolName === 'run_shell_command' && @@ -252,12 +334,16 @@ describe('createPolicyEngineConfig', () => { expect(denyRule!.priority).toBeGreaterThan(allowRule!.priority!); }); - it('should prioritize specific tool allows over MCP server excludes', () => { + it('should prioritize specific tool allows over MCP server excludes', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { mcp: { excluded: ['my-server'] }, tools: { allowed: ['my-server__specific-tool'] }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const serverDenyRule = config.rules?.find( (r) => @@ -270,15 +356,16 @@ describe('createPolicyEngineConfig', () => { ); expect(serverDenyRule).toBeDefined(); - expect(serverDenyRule?.priority).toBe(195); + expect(serverDenyRule?.priority).toBe(2.9); // MCP excluded server expect(toolAllowRule).toBeDefined(); - expect(toolAllowRule?.priority).toBe(100); + expect(toolAllowRule?.priority).toBeCloseTo(2.3, 5); // Command line allow - // Tool allow (100) has lower priority than server deny (195), - // so server deny wins - this might be counterintuitive + // Server deny (2.9) has higher priority than tool allow (2.3), + // so server deny wins (this is expected behavior - server-level blocks are security critical) }); - it('should prioritize specific tool excludes over MCP server allows', () => { + it('should handle MCP server allows and tool excludes', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { mcp: { allowed: ['my-server'] }, mcpServers: { @@ -290,7 +377,10 @@ describe('createPolicyEngineConfig', () => { }, tools: { exclude: ['my-server__dangerous-tool'] }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); const serverAllowRule = config.rules?.find( (r) => @@ -304,19 +394,22 @@ describe('createPolicyEngineConfig', () => { expect(serverAllowRule).toBeDefined(); expect(toolDenyRule).toBeDefined(); + // Command line exclude (2.4) has higher priority than MCP server trust (2.2) + // This is the correct behavior - specific exclusions should beat general server trust expect(toolDenyRule!.priority).toBeGreaterThan(serverAllowRule!.priority!); }); - it('should handle complex priority scenarios correctly', () => { + it('should handle complex priority scenarios correctly', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); 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 + autoAccept: true, // Not used in policy system (modes handle this) + allowed: ['my-server__tool1', 'other-tool'], // Priority 2.3 + exclude: ['my-server__tool2', 'glob'], // Priority 2.4 }, mcp: { - allowed: ['allowed-server'], // Priority 85 - excluded: ['excluded-server'], // Priority 195 + allowed: ['allowed-server'], // Priority 2.1 + excluded: ['excluded-server'], // Priority 2.9 }, mcpServers: { 'trusted-server': { @@ -326,7 +419,10 @@ describe('createPolicyEngineConfig', () => { }, }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); // Verify glob is denied even though autoAccept would allow it const globDenyRule = config.rules?.find( @@ -337,8 +433,10 @@ describe('createPolicyEngineConfig', () => { ); expect(globDenyRule).toBeDefined(); expect(globAllowRule).toBeDefined(); - expect(globDenyRule!.priority).toBe(200); - expect(globAllowRule!.priority).toBe(50); + // Deny from settings (user tier) + expect(globDenyRule!.priority).toBeCloseTo(2.4, 5); // Command line exclude + // Allow from default TOML: 1 + 50/1000 = 1.05 + expect(globAllowRule!.priority).toBeCloseTo(1.05, 5); // Verify all priority levels are correct const priorities = config.rules @@ -349,16 +447,17 @@ describe('createPolicyEngineConfig', () => { })) .sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)); - // Check that the highest priority items are the excludes + // Check that the highest priority items are the excludes (user tier: 2.4) const highestPriorityExcludes = priorities?.filter( - (p) => p.priority === 200, + (p) => Math.abs(p.priority! - 2.4) < 0.01, ); expect( highestPriorityExcludes?.every((p) => p.decision === PolicyDecision.DENY), ).toBe(true); }); - it('should handle MCP servers with undefined trust property', () => { + it('should handle MCP servers with undefined trust property', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { mcpServers: { 'no-trust-property': { @@ -373,7 +472,10 @@ describe('createPolicyEngineConfig', () => { }, }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); // Neither server should have an allow rule const noTrustRule = config.rules?.find( @@ -391,20 +493,22 @@ describe('createPolicyEngineConfig', () => { expect(explicitFalseRule).toBeUndefined(); }); - it('should not add write tool rules in YOLO mode', () => { + it('should have YOLO allow-all rule beat write tool rules in YOLO mode', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { tools: { exclude: ['dangerous-tool'] }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.YOLO); + const config = await createPolicyEngineConfig(settings, ApprovalMode.YOLO); - // Should have the wildcard allow rule with priority 0 + // Should have the wildcard allow rule const wildcardRule = config.rules?.find( - (r) => - !r.toolName && r.decision === PolicyDecision.ALLOW && r.priority === 0, + (r) => !r.toolName && r.decision === PolicyDecision.ALLOW, ); expect(wildcardRule).toBeDefined(); + // Priority 999 in default tier → 1.999 + expect(wildcardRule?.priority).toBeCloseTo(1.999, 5); - // Should NOT have any write tool rules (which would have priority 10) + // Write tool ASK_USER rules are present (no modes restriction now) const writeToolRules = config.rules?.filter( (r) => [ @@ -415,18 +519,24 @@ describe('createPolicyEngineConfig', () => { WEB_FETCH_TOOL_NAME, ].includes(r.toolName || '') && r.decision === PolicyDecision.ASK_USER, ); - expect(writeToolRules).toHaveLength(0); + expect(writeToolRules).toBeDefined(); - // Should still have the exclude rule + // But YOLO allow-all rule has higher priority than all write tool rules + writeToolRules?.forEach((writeRule) => { + expect(wildcardRule!.priority).toBeGreaterThan(writeRule.priority!); + }); + + // Should still have the exclude rule (from settings, user tier) const excludeRule = config.rules?.find( (r) => r.toolName === 'dangerous-tool' && r.decision === PolicyDecision.DENY, ); expect(excludeRule).toBeDefined(); - expect(excludeRule?.priority).toBe(200); + expect(excludeRule?.priority).toBeCloseTo(2.4, 5); // Command line exclude }); - it('should handle combination of trusted server and excluded server for same name', () => { + it('should handle combination of trusted server and excluded server for same name', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = { mcpServers: { 'conflicted-server': { @@ -439,7 +549,10 @@ describe('createPolicyEngineConfig', () => { excluded: ['conflicted-server'], // Priority 195 }, }; - const config = createPolicyEngineConfig(settings, ApprovalMode.DEFAULT); + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); // Both rules should exist const trustRule = config.rules?.find( @@ -454,18 +567,19 @@ describe('createPolicyEngineConfig', () => { ); expect(trustRule).toBeDefined(); - expect(trustRule?.priority).toBe(90); + expect(trustRule?.priority).toBe(2.2); // MCP trusted server expect(excludeRule).toBeDefined(); - expect(excludeRule?.priority).toBe(195); + expect(excludeRule?.priority).toBe(2.9); // MCP excluded server // Exclude (195) should win over trust (90) when evaluated }); - it('should handle all approval modes correctly', () => { + it('should handle all approval modes correctly', async () => { + const { createPolicyEngineConfig } = await import('./policy.js'); const settings: Settings = {}; // Test DEFAULT mode - const defaultConfig = createPolicyEngineConfig( + const defaultConfig = await createPolicyEngineConfig( settings, ApprovalMode.DEFAULT, ); @@ -477,16 +591,20 @@ describe('createPolicyEngineConfig', () => { ).toBeUndefined(); // Test YOLO mode - const yoloConfig = createPolicyEngineConfig(settings, ApprovalMode.YOLO); + const yoloConfig = await 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); + // Priority 999 in default tier → 1.999 + expect(yoloWildcard?.priority).toBeCloseTo(1.999, 5); // Test AUTO_EDIT mode - const autoEditConfig = createPolicyEngineConfig( + const autoEditConfig = await createPolicyEngineConfig( settings, ApprovalMode.AUTO_EDIT, ); @@ -495,6 +613,1044 @@ describe('createPolicyEngineConfig', () => { (r) => r.toolName === 'replace' && r.decision === PolicyDecision.ALLOW, ); expect(editRule).toBeDefined(); - expect(editRule?.priority).toBe(15); + // Priority 15 in default tier → 1.015 + expect(editRule?.priority).toBeCloseTo(1.015, 5); + }); + + it('should support argsPattern in policy rules', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'write.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/write.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +argsPattern = "\\"command\\":\\"git (status|diff|log)\\"" +decision = "allow" +priority = 150 +`; + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + + const rule = config.rules?.find( + (r) => + r.toolName === 'run_shell_command' && + r.decision === PolicyDecision.ALLOW, + ); + expect(rule).toBeDefined(); + // Priority 150 in user tier → 2.150 + expect(rule?.priority).toBeCloseTo(2.15, 5); + expect(rule?.argsPattern).toBeInstanceOf(RegExp); + expect(rule?.argsPattern?.test('{"command":"git status"}')).toBe(true); + expect(rule?.argsPattern?.test('{"command":"git diff"}')).toBe(true); + expect(rule?.argsPattern?.test('{"command":"git log"}')).toBe(true); + expect(rule?.argsPattern?.test('{"command":"git commit"}')).toBe(false); + expect(rule?.argsPattern?.test('{"command":"git push"}')).toBe(false); + + vi.doUnmock('node:fs/promises'); + }); + + it('should load and apply user-defined policies', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'write.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/write.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +decision = "allow" +priority = 150 +`; + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + + const rule = config.rules?.find( + (r) => + r.toolName === 'run_shell_command' && + r.decision === PolicyDecision.ALLOW, + ); + expect(rule).toBeDefined(); + // Priority 150 in user tier → 2.150 + expect(rule?.priority).toBeCloseTo(2.15, 5); + + vi.doUnmock('node:fs/promises'); + }); + + it('should load and apply admin policies over user and default policies', async () => { + process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] = '/tmp/admin/settings.json'; + + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if (typeof path === 'string') { + if ( + nodePath + .normalize(path) + .includes(nodePath.normalize('/tmp/admin/policies')) + ) { + return [ + { + name: 'write.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + if ( + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'write.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if ( + typeof path === 'string' && + (nodePath + .normalize(path) + .includes(nodePath.normalize('/tmp/admin/policies/write.toml')) || + path.endsWith('tmp/admin/policies/write.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +decision = "deny" +priority = 200 +`; + } + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/write.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +decision = "allow" +priority = 150 +`; + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await 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(); + // Priority 200 in admin tier → 3.200 + expect(denyRule?.priority).toBeCloseTo(3.2, 5); + expect(allowRule).toBeDefined(); + // Priority 150 in user tier → 2.150 + expect(allowRule?.priority).toBeCloseTo(2.15, 5); + expect(denyRule!.priority).toBeGreaterThan(allowRule!.priority!); + + delete process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']; + vi.doUnmock('node:fs/promises'); + }); + + it('should apply priority bands to ensure Admin > User > Default hierarchy', async () => { + process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] = '/tmp/admin/settings.json'; + + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if (typeof path === 'string') { + if ( + nodePath + .normalize(path) + .includes(nodePath.normalize('/tmp/admin/policies')) + ) { + return [ + { + name: 'admin-policy.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + if ( + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'user-policy.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if (typeof path === 'string') { + // Admin policy with low priority (100) + if ( + nodePath + .normalize(path) + .includes( + nodePath.normalize('/tmp/admin/policies/admin-policy.toml'), + ) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +decision = "deny" +priority = 100 +`; + } + // User policy with high priority (900) + if ( + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/user-policy.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +decision = "allow" +priority = 900 +`; + } + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + + const adminRule = config.rules?.find( + (r) => + r.toolName === 'run_shell_command' && + r.decision === PolicyDecision.DENY, + ); + const userRule = config.rules?.find( + (r) => + r.toolName === 'run_shell_command' && + r.decision === PolicyDecision.ALLOW, + ); + + expect(adminRule).toBeDefined(); + expect(userRule).toBeDefined(); + + // Admin priority should be 3.100 (tier 3 + 100/1000) + expect(adminRule?.priority).toBeCloseTo(3.1, 5); + // User priority should be 2.900 (tier 2 + 900/1000) + expect(userRule?.priority).toBeCloseTo(2.9, 5); + + // Admin rule with low priority should still beat user rule with high priority + expect(adminRule!.priority).toBeGreaterThan(userRule!.priority!); + + delete process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']; + vi.doUnmock('node:fs/promises'); + }); + + it('should apply correct priority transformations for each tier', async () => { + process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] = '/tmp/admin/settings.json'; + + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if (typeof path === 'string') { + if ( + nodePath + .normalize(path) + .includes(nodePath.normalize('/tmp/admin/policies')) + ) { + return [ + { + name: 'admin.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + if ( + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'user.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if (typeof path === 'string') { + if ( + nodePath + .normalize(path) + .includes(nodePath.normalize('/tmp/admin/policies/admin.toml')) + ) { + return ` +[[rule]] +toolName = "admin-tool" +decision = "allow" +priority = 500 +`; + } + if ( + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/user.toml')) + ) { + return ` +[[rule]] +toolName = "user-tool" +decision = "allow" +priority = 500 +`; + } + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + + const adminRule = config.rules?.find((r) => r.toolName === 'admin-tool'); + const userRule = config.rules?.find((r) => r.toolName === 'user-tool'); + + expect(adminRule).toBeDefined(); + expect(userRule).toBeDefined(); + + // Priority 500 in admin tier → 3.500 + expect(adminRule?.priority).toBeCloseTo(3.5, 5); + // Priority 500 in user tier → 2.500 + expect(userRule?.priority).toBeCloseTo(2.5, 5); + + delete process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH']; + vi.doUnmock('node:fs/promises'); + }); + + it('should support array syntax for toolName in TOML policies', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'array-test.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/array-test.toml')) + ) { + return ` +# Test array syntax for toolName +[[rule]] +toolName = ["tool1", "tool2", "tool3"] +decision = "allow" +priority = 100 + +# Test array syntax with mcpName +[[rule]] +mcpName = "google-workspace" +toolName = ["calendar.findFreeTime", "calendar.getEvent", "calendar.list"] +decision = "allow" +priority = 150 +`; + } + return actualFs.readFile( + path, + options as Parameters[1], + ); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + + // Should create separate rules for each tool in the array + const tool1Rule = config.rules?.find((r) => r.toolName === 'tool1'); + const tool2Rule = config.rules?.find((r) => r.toolName === 'tool2'); + const tool3Rule = config.rules?.find((r) => r.toolName === 'tool3'); + + expect(tool1Rule).toBeDefined(); + expect(tool2Rule).toBeDefined(); + expect(tool3Rule).toBeDefined(); + + // All should have the same decision and priority + expect(tool1Rule?.decision).toBe(PolicyDecision.ALLOW); + expect(tool2Rule?.decision).toBe(PolicyDecision.ALLOW); + expect(tool3Rule?.decision).toBe(PolicyDecision.ALLOW); + + // Priority 100 in user tier → 2.100 + expect(tool1Rule?.priority).toBeCloseTo(2.1, 5); + expect(tool2Rule?.priority).toBeCloseTo(2.1, 5); + expect(tool3Rule?.priority).toBeCloseTo(2.1, 5); + + // MCP tools should have composite names + const calendarFreeTime = config.rules?.find( + (r) => r.toolName === 'google-workspace__calendar.findFreeTime', + ); + const calendarGetEvent = config.rules?.find( + (r) => r.toolName === 'google-workspace__calendar.getEvent', + ); + const calendarList = config.rules?.find( + (r) => r.toolName === 'google-workspace__calendar.list', + ); + + expect(calendarFreeTime).toBeDefined(); + expect(calendarGetEvent).toBeDefined(); + expect(calendarList).toBeDefined(); + + // All should have the same decision and priority + expect(calendarFreeTime?.decision).toBe(PolicyDecision.ALLOW); + expect(calendarGetEvent?.decision).toBe(PolicyDecision.ALLOW); + expect(calendarList?.decision).toBe(PolicyDecision.ALLOW); + + // Priority 150 in user tier → 2.150 + expect(calendarFreeTime?.priority).toBeCloseTo(2.15, 5); + expect(calendarGetEvent?.priority).toBeCloseTo(2.15, 5); + expect(calendarList?.priority).toBeCloseTo(2.15, 5); + + vi.doUnmock('node:fs/promises'); + }); + + it('should support commandPrefix syntax for shell commands', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'shell.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/shell.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +commandPrefix = "git status" +decision = "allow" +priority = 100 +`; + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await 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).toBeCloseTo(2.1, 5); + expect(rule?.argsPattern).toBeInstanceOf(RegExp); + // Should match commands starting with "git status" + expect(rule?.argsPattern?.test('{"command":"git status"}')).toBe(true); + expect(rule?.argsPattern?.test('{"command":"git status --short"}')).toBe( + true, + ); + // Should not match other commands + expect(rule?.argsPattern?.test('{"command":"git branch"}')).toBe(false); + + vi.doUnmock('node:fs/promises'); + }); + + it('should support array syntax for commandPrefix', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'shell.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/shell.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +commandPrefix = ["git status", "git branch", "git log"] +decision = "allow" +priority = 100 +`; + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + + const rules = config.rules?.filter( + (r) => + r.toolName === 'run_shell_command' && + r.decision === PolicyDecision.ALLOW, + ); + + // Should create 3 rules (one for each prefix) + expect(rules?.length).toBe(3); + + // All rules should have the same priority and decision + rules?.forEach((rule) => { + expect(rule.priority).toBeCloseTo(2.1, 5); + expect(rule.decision).toBe(PolicyDecision.ALLOW); + }); + + // Test that each prefix pattern works + const patterns = rules?.map((r) => r.argsPattern); + expect(patterns?.some((p) => p?.test('{"command":"git status"}'))).toBe( + true, + ); + expect(patterns?.some((p) => p?.test('{"command":"git branch"}'))).toBe( + true, + ); + expect(patterns?.some((p) => p?.test('{"command":"git log"}'))).toBe(true); + // Should not match other commands + expect(patterns?.some((p) => p?.test('{"command":"git commit"}'))).toBe( + false, + ); + + vi.doUnmock('node:fs/promises'); + }); + + it('should support commandRegex syntax for shell commands', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'shell.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/shell.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +commandRegex = "git (status|branch|log).*" +decision = "allow" +priority = 100 +`; + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await 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).toBeCloseTo(2.1, 5); + expect(rule?.argsPattern).toBeInstanceOf(RegExp); + + // Should match commands matching the regex + expect(rule?.argsPattern?.test('{"command":"git status"}')).toBe(true); + expect(rule?.argsPattern?.test('{"command":"git status --short"}')).toBe( + true, + ); + expect(rule?.argsPattern?.test('{"command":"git branch"}')).toBe(true); + expect(rule?.argsPattern?.test('{"command":"git log --all"}')).toBe(true); + // Should not match commands not in the regex + expect(rule?.argsPattern?.test('{"command":"git commit"}')).toBe(false); + expect(rule?.argsPattern?.test('{"command":"git push"}')).toBe(false); + + vi.doUnmock('node:fs/promises'); + }); + + it('should escape regex special characters in commandPrefix', async () => { + const actualFs = + await vi.importActual( + 'node:fs/promises', + ); + + const mockReaddir = vi.fn( + async ( + path: string | Buffer | URL, + options?: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies')) + ) { + return [ + { + name: 'shell.toml', + isFile: () => true, + isDirectory: () => false, + }, + ] as unknown as Awaited>; + } + return actualFs.readdir( + path, + options as Parameters[1], + ); + }, + ); + + const mockReadFile = vi.fn( + async ( + path: Parameters[0], + options: Parameters[1], + ) => { + if ( + typeof path === 'string' && + nodePath + .normalize(path) + .includes(nodePath.normalize('.gemini/policies/shell.toml')) + ) { + return ` +[[rule]] +toolName = "run_shell_command" +commandPrefix = "git log *.txt" +decision = "allow" +priority = 100 +`; + } + return actualFs.readFile(path, options); + }, + ); + + vi.doMock('node:fs/promises', () => ({ + ...actualFs, + default: { ...actualFs, readFile: mockReadFile, readdir: mockReaddir }, + readFile: mockReadFile, + readdir: mockReaddir, + })); + + vi.resetModules(); + const { createPolicyEngineConfig } = await import('./policy.js'); + + const settings: Settings = {}; + const config = await createPolicyEngineConfig( + settings, + ApprovalMode.DEFAULT, + ); + + const rule = config.rules?.find( + (r) => + r.toolName === 'run_shell_command' && + r.decision === PolicyDecision.ALLOW, + ); + expect(rule).toBeDefined(); + // Should match the literal string "git log *.txt" (asterisk is escaped) + expect(rule?.argsPattern?.test('{"command":"git log *.txt"}')).toBe(true); + // Should not match "git log a.txt" because * is escaped to literal asterisk + expect(rule?.argsPattern?.test('{"command":"git log a.txt"}')).toBe(false); + + vi.doUnmock('node:fs/promises'); }); }); diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts index 0ebe8f06e0..7714780c47 100644 --- a/packages/cli/src/config/policy.ts +++ b/packages/cli/src/config/policy.ts @@ -8,88 +8,172 @@ import { type PolicyEngineConfig, PolicyDecision, type PolicyRule, - ApprovalMode, - // Read-only tools - GREP_TOOL_NAME, - LS_TOOL_NAME, - READ_MANY_FILES_TOOL_NAME, - READ_FILE_TOOL_NAME, - // Write tools - SHELL_TOOL_NAME, - WRITE_FILE_TOOL_NAME, - WEB_FETCH_TOOL_NAME, - GLOB_TOOL_NAME, - EDIT_TOOL_NAME, - MEMORY_TOOL_NAME, - WEB_SEARCH_TOOL_NAME, + type ApprovalMode, type PolicyEngine, type MessageBus, MessageBusType, type UpdatePolicy, + Storage, } from '@google/gemini-cli-core'; -import { type Settings } from './settings.js'; +import { type Settings, getSystemSettingsPath } from './settings.js'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + loadPoliciesFromToml, + type PolicyFileError, +} from './policy-toml-loader.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([ - GLOB_TOOL_NAME, - GREP_TOOL_NAME, - LS_TOOL_NAME, - READ_FILE_TOOL_NAME, - READ_MANY_FILES_TOOL_NAME, - WEB_SEARCH_TOOL_NAME, -]); +// Get the directory name of the current module +const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// 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([ - EDIT_TOOL_NAME, - MEMORY_TOOL_NAME, - SHELL_TOOL_NAME, - WRITE_FILE_TOOL_NAME, - WEB_FETCH_TOOL_NAME, -]); +// Store policy loading errors to be displayed after UI is ready +let storedPolicyErrors: string[] = []; -export function createPolicyEngineConfig( +function getPolicyDirectories(): string[] { + const DEFAULT_POLICIES_DIR = path.resolve(__dirname, 'policies'); + const USER_POLICIES_DIR = Storage.getUserPoliciesDir(); + const systemSettingsPath = getSystemSettingsPath(); + const ADMIN_POLICIES_DIR = path.join( + path.dirname(systemSettingsPath), + 'policies', + ); + + return [ + DEFAULT_POLICIES_DIR, + USER_POLICIES_DIR, + ADMIN_POLICIES_DIR, + ].reverse(); +} + +/** + * Determines the policy tier (1=default, 2=user, 3=admin) for a given directory. + * This is used by the TOML loader to assign priority bands. + */ +function getPolicyTier(dir: string): number { + const DEFAULT_POLICIES_DIR = path.resolve(__dirname, 'policies'); + const USER_POLICIES_DIR = Storage.getUserPoliciesDir(); + const systemSettingsPath = getSystemSettingsPath(); + const ADMIN_POLICIES_DIR = path.join( + path.dirname(systemSettingsPath), + 'policies', + ); + + // Normalize paths for comparison + const normalizedDir = path.resolve(dir); + const normalizedDefault = path.resolve(DEFAULT_POLICIES_DIR); + const normalizedUser = path.resolve(USER_POLICIES_DIR); + const normalizedAdmin = path.resolve(ADMIN_POLICIES_DIR); + + if (normalizedDir === normalizedDefault) return 1; + if (normalizedDir === normalizedUser) return 2; + if (normalizedDir === normalizedAdmin) return 3; + + // Default to tier 1 if unknown + return 1; +} + +/** + * Formats a policy file error for console logging. + */ +function formatPolicyError(error: PolicyFileError): string { + const tierLabel = error.tier.toUpperCase(); + let message = `[${tierLabel}] Policy file error in ${error.fileName}:\n`; + message += ` ${error.message}`; + if (error.details) { + message += `\n${error.details}`; + } + if (error.suggestion) { + message += `\n Suggestion: ${error.suggestion}`; + } + return message; +} + +export async function createPolicyEngineConfig( settings: Settings, approvalMode: ApprovalMode, -): PolicyEngineConfig { - const rules: PolicyRule[] = []; +): Promise { + const policyDirs = getPolicyDirectories(); + + // Load policies from TOML files + const { rules: tomlRules, errors } = await loadPoliciesFromToml( + approvalMode, + policyDirs, + getPolicyTier, + ); + + // Store any errors encountered during TOML loading + // These will be emitted by getPolicyErrorsForUI() after the UI is ready. + if (errors.length > 0) { + storedPolicyErrors = errors.map((error) => formatPolicyError(error)); + } + + const rules: PolicyRule[] = [...tomlRules]; // 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 - // 199: Tools that the user has selected as "Always Allow" in the interactive UI. - // 200: Explicitly excluded individual tools (highest priority) + // Priority bands (tiers): + // - Default policies (TOML): 1 + priority/1000 (e.g., priority 100 → 1.100) + // - User policies (TOML): 2 + priority/1000 (e.g., priority 100 → 2.100) + // - Admin policies (TOML): 3 + priority/1000 (e.g., priority 100 → 3.100) + // + // This ensures Admin > User > Default hierarchy is always preserved, + // while allowing user-specified priorities to work within each tier. + // + // Settings-based and dynamic rules (all in user tier 2.x): + // 2.95: Tools that the user has selected as "Always Allow" in the interactive UI + // 2.9: MCP servers excluded list (security: persistent server blocks) + // 2.4: Command line flag --exclude-tools (explicit temporary blocks) + // 2.3: Command line flag --allowed-tools (explicit temporary allows) + // 2.2: MCP servers with trust=true (persistent trusted servers) + // 2.1: MCP servers allowed list (persistent general server allows) + // + // TOML policy priorities (before transformation): + // 10: Write tools default to ASK_USER (becomes 1.010 in default tier) + // 15: Auto-edit tool override (becomes 1.015 in default tier) + // 50: Read-only tools (becomes 1.050 in default tier) + // 999: YOLO mode allow-all (becomes 1.999 in default tier) - // 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) { + // MCP servers that are explicitly excluded in settings.mcp.excluded + // Priority: 2.9 (highest in user tier for security - persistent server blocks) + if (settings.mcp?.excluded) { + for (const serverName of settings.mcp.excluded) { rules.push({ toolName: `${serverName}__*`, + decision: PolicyDecision.DENY, + priority: 2.9, + }); + } + } + + // Tools that are explicitly excluded in the settings. + // Priority: 2.4 (user tier - explicit temporary blocks) + if (settings.tools?.exclude) { + for (const tool of settings.tools.exclude) { + rules.push({ + toolName: tool, + decision: PolicyDecision.DENY, + priority: 2.4, + }); + } + } + + // Tools that are explicitly allowed in the settings. + // Priority: 2.3 (user tier - explicit temporary allows) + if (settings.tools?.allowed) { + for (const tool of settings.tools.allowed) { + rules.push({ + toolName: tool, decision: PolicyDecision.ALLOW, - priority: 85, + priority: 2.3, }); } } // MCP servers that are trusted in the settings. - // Priority: 90 (higher than general allowed servers but lower than explicit tool allows) + // Priority: 2.2 (user tier - persistent trusted servers) if (settings.mcpServers) { for (const [serverName, serverConfig] of Object.entries( settings.mcpServers, @@ -100,83 +184,24 @@ export function createPolicyEngineConfig( rules.push({ toolName: `${serverName}__*`, decision: PolicyDecision.ALLOW, - priority: 90, + priority: 2.2, }); } } } - // 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) { + // MCP servers that are explicitly allowed in settings.mcp.allowed + // Priority: 2.1 (user tier - persistent general server allows) + if (settings.mcp?.allowed) { + for (const serverName of settings.mcp.allowed) { rules.push({ toolName: `${serverName}__*`, - decision: PolicyDecision.DENY, - priority: 195, + decision: PolicyDecision.ALLOW, + priority: 2.1, }); } } - // Allow all read-only tools. - // Priority: 50 - 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: EDIT_TOOL_NAME, - decision: PolicyDecision.ALLOW, - priority: 15, // Higher than write tools (10) to override ASK_USER - }); - } - return { rules, defaultDecision: PolicyDecision.ASK_USER, @@ -195,8 +220,23 @@ export function createPolicyUpdater( policyEngine.addRule({ toolName, decision: PolicyDecision.ALLOW, - priority: 199, // High priority, but lower than explicit DENY (200) + // User tier (2) + high priority (950/1000) = 2.95 + // This ensures user "always allow" selections are high priority + // but still lose to admin policies (3.xxx) and settings excludes (200) + priority: 2.95, }); }, ); } + +/** + * Gets and clears any policy errors that were stored during config loading. + * This should be called once the UI is ready to display errors. + * + * @returns Array of formatted error messages, or empty array if no errors + */ +export function getPolicyErrorsForUI(): string[] { + const errors = [...storedPolicyErrors]; + storedPolicyErrors = []; // Clear after retrieving + return errors; +} diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index a81ad63280..eef68e4e03 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -50,6 +50,7 @@ import { } from '@google/gemini-cli-core'; import { validateAuthMethod } from '../config/auth.js'; import { loadHierarchicalGeminiMemory } from '../config/config.js'; +import { getPolicyErrorsForUI } from '../config/policy.js'; import process from 'node:process'; import { useHistory } from './hooks/useHistoryManager.js'; import { useMemoryMonitor } from './hooks/useMemoryMonitor.js'; @@ -885,11 +886,23 @@ Logging in with Google... Please restart Gemini CLI to continue. }; appEvents.on(AppEvent.LogError, logErrorHandler); + // Emit any policy errors that were stored during config loading + // Only show these when message bus integration is enabled, as policies + // are only active when the message bus is being used. + if (config.getEnableMessageBusIntegration()) { + const policyErrors = getPolicyErrorsForUI(); + if (policyErrors.length > 0) { + for (const error of policyErrors) { + appEvents.emit(AppEvent.LogError, error); + } + } + } + return () => { appEvents.off(AppEvent.OpenDebugConsole, openDebugConsole); appEvents.off(AppEvent.LogError, logErrorHandler); }; - }, [handleNewMessage]); + }, [handleNewMessage, config]); useEffect(() => { if (ctrlCTimerRef.current) { diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 398914ae5a..969dc0a91e 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1101,6 +1101,11 @@ export class Config { async createToolRegistry(): Promise { const registry = new ToolRegistry(this, this.eventEmitter); + // Set message bus on tool registry before discovery so MCP tools can access it + if (this.getEnableMessageBusIntegration()) { + registry.setMessageBus(this.messageBus); + } + // helper to create & register core tools that are enabled // eslint-disable-next-line @typescript-eslint/no-explicit-any const registerCoreTool = (ToolClass: any, ...args: unknown[]) => { diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 18c0a4faee..75883698ac 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -54,6 +54,10 @@ export class Storage { return path.join(Storage.getGlobalGeminiDir(), 'memory.md'); } + static getUserPoliciesDir(): string { + return path.join(Storage.getGlobalGeminiDir(), 'policies'); + } + static getGlobalTempDir(): string { return path.join(Storage.getGlobalGeminiDir(), TMP_DIR_NAME); } diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts index cf4388c09d..cc98cc5966 100644 --- a/packages/core/src/policy/policy-engine.ts +++ b/packages/core/src/policy/policy-engine.ts @@ -11,6 +11,7 @@ import { type PolicyRule, } from './types.js'; import { stableStringify } from './stable-stringify.js'; +import { debugLogger } from '../utils/debugLogger.js'; function ruleMatches( rule: PolicyRule, @@ -71,14 +72,24 @@ export class PolicyEngine { stringifiedArgs = stableStringify(toolCall.args); } + debugLogger.debug( + `[PolicyEngine.check] toolCall.name: ${toolCall.name}, stringifiedArgs: ${stringifiedArgs}`, + ); + // Find the first matching rule (already sorted by priority) for (const rule of this.rules) { if (ruleMatches(rule, toolCall, stringifiedArgs)) { + debugLogger.debug( + `[PolicyEngine.check] MATCHED rule: toolName=${rule.toolName}, decision=${rule.decision}, priority=${rule.priority}, argsPattern=${rule.argsPattern?.source || 'none'}`, + ); return this.applyNonInteractiveMode(rule.decision); } } // No matching rule found, use default decision + debugLogger.debug( + `[PolicyEngine.check] NO MATCH - using default decision: ${this.defaultDecision}`, + ); return this.applyNonInteractiveMode(this.defaultDecision); } diff --git a/packages/core/src/tools/mcp-client.test.ts b/packages/core/src/tools/mcp-client.test.ts index 23760e9914..5ff64eb0a8 100644 --- a/packages/core/src/tools/mcp-client.test.ts +++ b/packages/core/src/tools/mcp-client.test.ts @@ -89,6 +89,7 @@ describe('mcp-client', () => { } as unknown as GenAiLib.CallableTool); const mockedToolRegistry = { registerTool: vi.fn(), + getMessageBus: vi.fn().mockReturnValue(undefined), } as unknown as ToolRegistry; const client = new McpClient( 'test-server', @@ -152,6 +153,7 @@ describe('mcp-client', () => { } as unknown as GenAiLib.CallableTool); const mockedToolRegistry = { registerTool: vi.fn(), + getMessageBus: vi.fn().mockReturnValue(undefined), } as unknown as ToolRegistry; const client = new McpClient( 'test-server', @@ -190,12 +192,16 @@ describe('mcp-client', () => { vi.mocked(GenAiLib.mcpToTool).mockReturnValue({ tool: () => Promise.resolve({ functionDeclarations: [] }), } as unknown as GenAiLib.CallableTool); + const mockedToolRegistry = { + registerTool: vi.fn(), + getMessageBus: vi.fn().mockReturnValue(undefined), + } as unknown as ToolRegistry; const client = new McpClient( 'test-server', { command: 'test-command', }, - {} as ToolRegistry, + mockedToolRegistry, {} as PromptRegistry, workspaceContext, false, @@ -231,6 +237,7 @@ describe('mcp-client', () => { const mockedMcpToTool = vi.mocked(GenAiLib.mcpToTool); const mockedToolRegistry = { registerTool: vi.fn(), + getMessageBus: vi.fn().mockReturnValue(undefined), } as unknown as ToolRegistry; const client = new McpClient( 'test-server', @@ -279,6 +286,7 @@ describe('mcp-client', () => { } as unknown as GenAiLib.CallableTool); const mockedToolRegistry = { registerTool: vi.fn(), + getMessageBus: vi.fn().mockReturnValue(undefined), } as unknown as ToolRegistry; const client = new McpClient( 'test-server', diff --git a/packages/core/src/tools/mcp-client.ts b/packages/core/src/tools/mcp-client.ts index a2afc04736..6457fc1cb3 100644 --- a/packages/core/src/tools/mcp-client.ts +++ b/packages/core/src/tools/mcp-client.ts @@ -42,6 +42,7 @@ import type { } from '../utils/workspaceContext.js'; import type { ToolRegistry } from './tool-registry.js'; import { debugLogger } from '../utils/debugLogger.js'; +import type { MessageBus } from '../confirmation-bus/message-bus.js'; import { coreEvents } from '../utils/events.js'; export const MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000; // default to 10 minutes @@ -198,6 +199,7 @@ export class McpClient { this.serverConfig, this.client!, cliConfig, + this.toolRegistry.getMessageBus(), ); } @@ -545,6 +547,7 @@ export async function connectAndDiscover( mcpServerConfig, mcpClient, cliConfig, + toolRegistry.getMessageBus(), ); // If we have neither prompts nor tools, it's a failed discovery @@ -582,6 +585,8 @@ export async function connectAndDiscover( * @param mcpServerName The name of the MCP server. * @param mcpServerConfig The configuration for the MCP server. * @param mcpClient The active MCP client instance. + * @param cliConfig The CLI configuration object. + * @param messageBus Optional message bus for policy engine integration. * @returns A promise that resolves to an array of discovered and enabled tools. * @throws An error if no enabled tools are found or if the server provides invalid function declarations. */ @@ -590,6 +595,7 @@ export async function discoverTools( mcpServerConfig: MCPServerConfig, mcpClient: Client, cliConfig: Config, + messageBus?: MessageBus, ): Promise { try { // Only request tools if the server supports them. @@ -612,19 +618,29 @@ export async function discoverTools( continue; } - discoveredTools.push( - new DiscoveredMCPTool( - mcpCallableTool, - mcpServerName, - funcDecl.name!, - funcDecl.description ?? '', - funcDecl.parametersJsonSchema ?? { type: 'object', properties: {} }, - mcpServerConfig.trust, - undefined, - cliConfig, - mcpServerConfig.extension?.id, - ), + const tool = new DiscoveredMCPTool( + mcpCallableTool, + mcpServerName, + funcDecl.name!, + funcDecl.description ?? '', + funcDecl.parametersJsonSchema ?? { type: 'object', properties: {} }, + mcpServerConfig.trust, + undefined, + cliConfig, + mcpServerConfig.extension?.id, + messageBus, ); + + if ( + cliConfig.getDebugMode?.() && + cliConfig.getEnableMessageBusIntegration?.() + ) { + debugLogger.log( + `[DEBUG] Discovered MCP tool '${funcDecl.name}' from server '${mcpServerName}' with messageBus: ${messageBus ? 'YES' : 'NO'}`, + ); + } + + discoveredTools.push(tool); } catch (error) { coreEvents.emitFeedback( 'error', diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index 822a41f24f..182366a6dc 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -72,11 +72,15 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation< readonly trust?: boolean, params: ToolParams = {}, private readonly cliConfig?: Config, + messageBus?: MessageBus, ) { - super(params); + // Use composite format for policy checks: serverName__toolName + // This enables server wildcards (e.g., "google-workspace__*") + // while still allowing specific tool rules + super(params, messageBus, `${serverName}__${serverToolName}`, displayName); } - override async shouldConfirmExecute( + protected override async getConfirmationDetails( _abortSignal: AbortSignal, ): Promise { const serverAllowListKey = this.serverName; @@ -215,6 +219,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< nameOverride?: string, private readonly cliConfig?: Config, override readonly extensionId?: string, + messageBus?: MessageBus, ) { super( nameOverride ?? generateValidName(serverToolName), @@ -223,8 +228,8 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< Kind.Other, parameterSchema, true, // isOutputMarkdown - false, // canUpdateOutput - undefined, // messageBus + false, // canUpdateOutput, + messageBus, extensionId, ); } @@ -240,6 +245,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< `${this.serverName}__${this.serverToolName}`, this.cliConfig, this.extensionId, + this.messageBus, ); } @@ -257,6 +263,7 @@ export class DiscoveredMCPTool extends BaseDeclarativeTool< this.trust, params, this.cliConfig, + _messageBus, ); } } diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index ba67c8adcf..11c028b7fc 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -60,8 +60,10 @@ export class ShellToolInvocation extends BaseToolInvocation< params: ShellToolParams, private readonly allowlist: Set, messageBus?: MessageBus, + _toolName?: string, + _toolDisplayName?: string, ) { - super(params, messageBus); + super(params, messageBus, _toolName, _toolDisplayName); } getDescription(): string { @@ -451,12 +453,16 @@ export class ShellTool extends BaseDeclarativeTool< protected createInvocation( params: ShellToolParams, messageBus?: MessageBus, + _toolName?: string, + _toolDisplayName?: string, ): ToolInvocation { return new ShellToolInvocation( this.config, params, this.allowlist, messageBus, + _toolName, + _toolDisplayName, ); } } diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index 4a4a13c5f9..c7d8e35305 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -177,12 +177,21 @@ export class ToolRegistry { private tools: Map = new Map(); private config: Config; private mcpClientManager: McpClientManager; + private messageBus?: MessageBus; constructor(config: Config, eventEmitter?: EventEmitter) { this.config = config; this.mcpClientManager = new McpClientManager(this, eventEmitter); } + setMessageBus(messageBus: MessageBus): void { + this.messageBus = messageBus; + } + + getMessageBus(): MessageBus | undefined { + return this.messageBus; + } + /** * Registers a tool definition. * @param tool - The tool object containing schema and execution logic. diff --git a/packages/core/src/tools/write-todos.ts b/packages/core/src/tools/write-todos.ts index 8f80904c85..73377b8255 100644 --- a/packages/core/src/tools/write-todos.ts +++ b/packages/core/src/tools/write-todos.ts @@ -99,6 +99,15 @@ class WriteTodosToolInvocation extends BaseToolInvocation< WriteTodosToolParams, ToolResult > { + constructor( + params: WriteTodosToolParams, + messageBus?: MessageBus, + _toolName?: string, + _toolDisplayName?: string, + ) { + super(params, messageBus, _toolName, _toolDisplayName); + } + getDescription(): string { const count = this.params.todos?.length ?? 0; if (count === 0) { @@ -209,6 +218,11 @@ export class WriteTodosTool extends BaseDeclarativeTool< _toolName?: string, _displayName?: string, ): ToolInvocation { - return new WriteTodosToolInvocation(params); + return new WriteTodosToolInvocation( + params, + _messageBus, + _toolName, + _displayName, + ); } } diff --git a/scripts/copy_files.js b/scripts/copy_files.js index ddf254641d..4e32e61e00 100644 --- a/scripts/copy_files.js +++ b/scripts/copy_files.js @@ -26,7 +26,7 @@ import path from 'node:path'; const sourceDir = path.join('src'); const targetDir = path.join('dist', 'src'); -const extensionsToCopy = ['.md', '.json', '.sb']; +const extensionsToCopy = ['.md', '.json', '.sb', '.toml']; function copyFilesRecursive(source, target) { if (!fs.existsSync(target)) {