feat(policy): Introduce config-based policy engine with TOML configuration (#11992)

This commit is contained in:
Allen Hutchison
2025-10-28 09:20:57 -07:00
committed by GitHub
parent 1b302deeff
commit 064edc52f5
20 changed files with 3146 additions and 271 deletions

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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"]

View File

@@ -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);

View File

@@ -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<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
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<string> => {
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<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
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<string> => {
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<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
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<string> => {
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<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
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<string> => {
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<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
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<string> => {
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<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
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<string> => {
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<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
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<string> => {
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<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
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<string> => {
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<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
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<string> => {
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<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
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<string> => {
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<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
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<string> => {
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<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
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<string> => {
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<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(async (_path: string): Promise<Dirent[]> => {
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<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
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<string> => {
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<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async (
path: string,
_options?: { withFileTypes: boolean },
): Promise<Dirent[]> => {
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<string> => {
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');
});
});
});

View File

@@ -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<typeof PolicyRuleSchema>;
/**
* 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<PolicyLoadResult> {
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<string | undefined> =
commandPrefixes.length > 0
? commandPrefixes.map(
(prefix) => `"command":"${escapeRegex(prefix)}`,
)
: [effectiveArgsPattern];
// For each argsPattern, expand toolName arrays
return argsPatterns.flatMap((argsPattern) => {
const toolNames: Array<string | undefined> = 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 };
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<PolicyEngineConfig> {
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;
}

View File

@@ -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) {

View File

@@ -1101,6 +1101,11 @@ export class Config {
async createToolRegistry(): Promise<ToolRegistry> {
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[]) => {

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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',

View File

@@ -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<DiscoveredMCPTool[]> {
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',

View File

@@ -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<ToolCallConfirmationDetails | false> {
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,
);
}
}

View File

@@ -60,8 +60,10 @@ export class ShellToolInvocation extends BaseToolInvocation<
params: ShellToolParams,
private readonly allowlist: Set<string>,
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<ShellToolParams, ToolResult> {
return new ShellToolInvocation(
this.config,
params,
this.allowlist,
messageBus,
_toolName,
_toolDisplayName,
);
}
}

View File

@@ -177,12 +177,21 @@ export class ToolRegistry {
private tools: Map<string, AnyDeclarativeTool> = 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.

View File

@@ -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<WriteTodosToolParams, ToolResult> {
return new WriteTodosToolInvocation(params);
return new WriteTodosToolInvocation(
params,
_messageBus,
_toolName,
_displayName,
);
}
}

View File

@@ -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)) {