diff --git a/docs/extensions/index.md b/docs/extensions/index.md index af886026d70..7168c525220 100644 --- a/docs/extensions/index.md +++ b/docs/extensions/index.md @@ -275,3 +275,70 @@ using `"cwd": "${extensionPath}${/}run.ts"`. | `${extensionPath}` | The fully-qualified path of the extension in the user's filesystem e.g., '/Users/username/.gemini/extensions/example-extension'. This will not unwrap symlinks. | | `${workspacePath}` | The fully-qualified path of the current workspace. | | `${/} or ${pathSeparator}` | The path separator (differs per OS). | + +### Extension-Contributed Settings + +Extensions can contribute settings to the Gemini CLI, allowing for user-specific +configuration. These settings can either prompt the user for values (e.g., API +keys) or override existing Gemini CLI settings (e.g., +`context.includeDirectories`). + +**Permissions and Precedence:** + +For security, users must grant permission for an extension to contribute +settings. When an extension is installed, the user is prompted to review and +approve the settings. Users can allow or deny individual contributions, or +"always allow" an extension to skip future prompts. + +Settings are applied with the following order of precedence (from lowest to +highest): + +1. System Defaults +2. User Settings +3. Extension Settings +4. Workspace Settings +5. System Settings + +This means that a setting defined in a higher precedence source will override a +setting from a lower precedence source. + +**Activation and Drift Detection:** + +Extension-contributed settings are only active when the extension is enabled. If +an extension's `gemini-extension.json` file changes, the user will be +re-prompted to grant permission for new or modified settings, ensuring user +control. + +**Settings that CANNOT be overridden by extensions (for security and privacy +reasons):** + +- Any setting under `security` (e.g., authentication, folder trust). +- Any setting under `telemetry` or `privacy`. +- `tools.sandbox`. +- The `trust` field within `mcpServers` configurations. +- `tools.autoAccept` cannot be set to `true`. + +**Example `gemini-extension.json` overriding `context.includeDirectories`:** + +```json +{ + "name": "my-context-extension", + "version": "1.0.0", + "context": { + "includeDirectories": ["src/my-feature", "docs/my-feature"] + } +} +``` + +When this extension is installed and approved, it will create an +`extension-settings.json` file in the extension's directory (e.g., +`/.gemini/extensions/my-context-extension/extension-settings.json`) with +the following content: + +```json +{ + "context": { + "includeDirectories": ["src/my-feature", "docs/my-feature"] + } +} +``` diff --git a/packages/cli/src/config/extension-manager.test.ts b/packages/cli/src/config/extension-manager.test.ts new file mode 100644 index 00000000000..dc55aeb6206 --- /dev/null +++ b/packages/cli/src/config/extension-manager.test.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Mock OS and FS before importing modules that use them +vi.mock('node:fs'); +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: vi.fn(() => '/mock/home/user'), + }; +}); +vi.mock('./trustedFolders.js', () => ({ + isWorkspaceTrusted: vi.fn(() => ({ isTrusted: true })), + loadTrustedFolders: vi.fn(() => []), + TrustLevel: { Full: 'full', Partial: 'partial' }, +})); +vi.mock('./extensions/github.js'); + +import { ExtensionManager } from './extension-manager.js'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { ExtensionStorage } from './extensions/storage.js'; +import { EXTENSION_CONFIG_SETTINGS_FILENAME } from './extensions/variables.js'; +import type { Settings } from './settings.js'; + +describe('ExtensionManager', () => { + let extensionManager: ExtensionManager; + const mockRequestConsent = vi.fn(); + const mockRequestSetting = vi.fn(); + + beforeEach(() => { + extensionManager = new ExtensionManager({ + settings: {} as Settings, + requestConsent: mockRequestConsent, + requestSetting: mockRequestSetting, + workspaceDir: '/test/workspace', + }); + vi.spyOn(fs.promises, 'mkdir').mockResolvedValue(undefined); + vi.spyOn(fs.promises, 'writeFile').mockResolvedValue(undefined); + vi.spyOn(fs.promises, 'rm').mockResolvedValue(undefined); + vi.spyOn(fs, 'existsSync').mockReturnValue(true); + vi.spyOn(fs, 'readdirSync').mockReturnValue([]); + vi.spyOn(fs, 'readFileSync').mockReturnValue( + JSON.stringify({ + name: 'test-extension', + version: '1.0.0', + context: { + includeDirectories: ['test'], + }, + hooks: { + beforeTool: 'echo "test"', + }, + }), + ); + vi.spyOn(fs, 'statSync').mockReturnValue({ + isDirectory: () => true, + } as fs.Stats); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should write settings to extension-settings.json on install', async () => { + mockRequestConsent.mockResolvedValue(true); + const installMetadata = { + source: '/test/extension', + type: 'local' as const, + }; + + // Load extensions first (required before install/update) + await extensionManager.loadExtensions(); + + await extensionManager.installOrUpdateExtension(installMetadata); + + const expectedSettings = { + context: { + includeDirectories: ['test'], + }, + hooks: { + beforeTool: 'echo "test"', + }, + }; + const extensionDir = new ExtensionStorage( + 'test-extension', + ).getExtensionDir(); + const settingsPath = path.join( + extensionDir, + EXTENSION_CONFIG_SETTINGS_FILENAME, + ); + + expect(fs.promises.writeFile).toHaveBeenCalledWith( + settingsPath, + JSON.stringify(expectedSettings, null, 2), + ); + }); +}); diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index cc2388585ef..335b834d202 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -46,6 +46,7 @@ import { maybeRequestConsentOrFail } from './extensions/consent.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { ExtensionStorage } from './extensions/storage.js'; import { + EXTENSION_CONFIG_SETTINGS_FILENAME, EXTENSIONS_CONFIG_FILENAME, INSTALL_METADATA_FILENAME, recursivelyHydrateStrings, @@ -57,6 +58,8 @@ import { type ExtensionSetting, } from './extensions/extensionSettings.js'; import type { EventEmitter } from 'node:stream'; +import { ExtensionSettingsValidator } from './extension-settings-validator.js'; +import type { ExtensionSettings } from './extension-settings.js'; interface ExtensionManagerParams { enabledExtensionOverrides?: string[]; @@ -281,6 +284,19 @@ export class ExtensionManager extends ExtensionLoader { } } + const settingContributions = + await maybeValidateAndPromptForSettingContributions( + newExtensionConfig, + this.requestConsent, + previousExtensionConfig, + ); + if (settingContributions) { + await fs.promises.writeFile( + path.join(destinationPath, EXTENSION_CONFIG_SETTINGS_FILENAME), + JSON.stringify(settingContributions, null, 2), + ); + } + if ( installMetadata.type === 'local' || installMetadata.type === 'git' || @@ -392,6 +408,7 @@ export class ExtensionManager extends ExtensionLoader { await this.unloadExtension(extension); const storage = new ExtensionStorage(extension.name); + // Delete the entire extension directory (including extension-settings.json) await fs.promises.rm(storage.getExtensionDir(), { recursive: true, force: true, @@ -671,6 +688,80 @@ export class ExtensionManager extends ExtensionLoader { } } +const extensionSettingKeys: Array = [ + 'general', + 'output', + 'ui', + 'ide', + 'context', + 'tools', + 'model', + 'modelConfigs', + 'mcp', + 'useSmartEdit', + 'useWriteTodos', + 'advanced', + 'experimental', + 'hooks', +]; + +function extractExtensionSettings(config: ExtensionConfig): ExtensionSettings { + const settings: ExtensionSettings = {}; + for (const key of extensionSettingKeys) { + if ( + key in config && + (config as unknown as Record)[key] !== undefined + ) { + (settings as unknown as Record)[key] = ( + config as unknown as Record + )[key]; + } + } + return settings; +} + +async function maybeValidateAndPromptForSettingContributions( + config: ExtensionConfig, + requestConsent: (consent: string) => Promise, + previousExtensionConfig?: ExtensionConfig, +): Promise { + const validator = new ExtensionSettingsValidator(); + const settingsToValidate = extractExtensionSettings(config); + + const validationResult = validator.validate(settingsToValidate); + if (!validationResult.valid) { + debugLogger.error( + `Invalid setting contributions in extension "${config.name}":`, + ); + for (const error of validationResult.errors) { + debugLogger.error(`- ${error}`); + } + return null; + } + + const previousSettings = previousExtensionConfig + ? extractExtensionSettings(previousExtensionConfig) + : {}; + + if ( + Object.keys(settingsToValidate).length > 0 && + JSON.stringify(settingsToValidate) !== JSON.stringify(previousSettings) + ) { + let consentString = + 'This extension would like to modify the following settings:'; + for (const [key, value] of Object.entries(settingsToValidate)) { + consentString += `\n- ${key}: ${JSON.stringify(value)}`; + } + consentString += + '\nDo you want to allow this extension to modify these settings?'; + + if (await requestConsent(consentString)) { + return settingsToValidate; + } + } + return null; +} + function filterMcpConfig(original: MCPServerConfig): MCPServerConfig { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { trust, ...rest } = original; diff --git a/packages/cli/src/config/extension-settings-validator.test.ts b/packages/cli/src/config/extension-settings-validator.test.ts new file mode 100644 index 00000000000..fc39c49d9c0 --- /dev/null +++ b/packages/cli/src/config/extension-settings-validator.test.ts @@ -0,0 +1,364 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { ExtensionSettingsValidator } from './extension-settings-validator.js'; +import type { ExtensionSettings } from './extension-settings.js'; + +describe('ExtensionSettingsValidator', () => { + const validator = new ExtensionSettingsValidator(); + + describe('allowed settings', () => { + it('should allow valid general settings', () => { + const settings: ExtensionSettings = { + general: { + vimMode: true, + preferredEditor: 'code', + disableAutoUpdate: false, + }, + }; + + const result = validator.validate(settings); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should allow valid context settings', () => { + const settings: ExtensionSettings = { + context: { + includeDirectories: ['src/', 'lib/'], + fileName: 'CONTEXT.md', + fileFiltering: { + respectGitIgnore: true, + respectGeminiIgnore: false, + enableRecursiveFileSearch: true, + disableFuzzySearch: false, + }, + loadMemoryFromIncludeDirectories: true, + }, + }; + + const result = validator.validate(settings); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should allow valid tool settings', () => { + const settings: ExtensionSettings = { + tools: { + exclude: ['dangerous-tool'], + allowed: ['safe-tool'], + autoAccept: false, // Only false is allowed + discoveryCommand: 'custom-discover', + callCommand: 'custom-call', + useRipgrep: true, + enableToolOutputTruncation: true, + truncateToolOutputThreshold: 1000, + truncateToolOutputLines: 50, + }, + }; + + const result = validator.validate(settings); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should allow valid UI settings', () => { + const settings: ExtensionSettings = { + ui: { + customWittyPhrases: ['Testing...', 'Almost there...'], + hideTips: true, + hideBanner: false, + hideContextSummary: true, + hideFooter: false, + showLineNumbers: true, + showCitations: false, + useFullWidth: true, + }, + }; + + const result = validator.validate(settings); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should allow valid model settings', () => { + const settings: ExtensionSettings = { + model: { + name: 'gemini-2.5-pro', + maxSessionTurns: 100, + skipNextSpeakerCheck: true, + compressionThreshold: 0.3, + }, + }; + + const result = validator.validate(settings); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should allow hooks settings', () => { + const settings: ExtensionSettings = { + hooks: { + beforeTool: 'echo "Running tool"', + afterModel: 'echo "Model finished"', + customHook: { + command: 'my-command', + args: ['arg1', 'arg2'], + }, + }, + }; + + const result = validator.validate(settings); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should allow mcpServers settings', () => { + const settings: ExtensionSettings = { + mcpServers: { + myServer: { + command: 'my-mcp-server', + args: ['--verbose'], + }, + }, + }; + + const result = validator.validate(settings); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should allow experimental settings', () => { + const settings: ExtensionSettings = { + experimental: { + useModelRouter: true, + codebaseInvestigatorSettings: { + enabled: true, + maxNumTurns: 5, + }, + }, + }; + + const result = validator.validate(settings); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should allow advanced settings', () => { + const settings: ExtensionSettings = { + advanced: { + excludedEnvVars: ['MY_VAR'], + autoConfigureMemory: true, + }, + }; + + const result = validator.validate(settings); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('should allow multiple settings categories', () => { + const settings: ExtensionSettings = { + context: { + includeDirectories: ['src/'], + }, + ui: { + hideTips: true, + }, + hooks: { + beforeTool: 'echo test', + }, + }; + + const result = validator.validate(settings); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe('forbidden settings', () => { + it('should reject security settings', () => { + const settings = { + security: { + auth: { + enabled: true, + }, + }, + } as unknown as ExtensionSettings; + + const result = validator.validate(settings); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors.some((e) => e.includes('security'))).toBe(true); + expect(result.errors.some((e) => e.includes('forbidden'))).toBe(true); + }); + + it('should reject telemetry settings', () => { + const settings = { + telemetry: { + enabled: false, + }, + } as unknown as ExtensionSettings; + + const result = validator.validate(settings); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors.some((e) => e.includes('telemetry'))).toBe(true); + expect(result.errors.some((e) => e.includes('forbidden'))).toBe(true); + }); + + it('should reject privacy settings', () => { + const settings = { + privacy: { + usageStatisticsEnabled: false, + }, + } as unknown as ExtensionSettings; + + const result = validator.validate(settings); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors.some((e) => e.includes('privacy'))).toBe(true); + expect(result.errors.some((e) => e.includes('forbidden'))).toBe(true); + }); + + it('should reject tools.sandbox settings', () => { + const settings = { + tools: { + sandbox: true, + }, + } as unknown as ExtensionSettings; + + const result = validator.validate(settings); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors.some((e) => e.includes('sandbox'))).toBe(true); + }); + + it('should reject mcpServers trust field', () => { + const settings = { + mcpServers: { + myServer: { + command: 'my-server', + trust: true, + }, + }, + } as unknown as ExtensionSettings; + + const result = validator.validate(settings); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors.some((e) => e.includes('trust'))).toBe(true); + }); + }); + + describe('special rules', () => { + it('should reject tools.autoAccept when set to true', () => { + const settings: ExtensionSettings = { + tools: { + autoAccept: true, + }, + }; + + const result = validator.validate(settings); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors.some((e) => e.includes('autoAccept'))).toBe(true); + expect(result.errors.some((e) => e.includes('true'))).toBe(true); + }); + + it('should allow tools.autoAccept when set to false', () => { + const settings: ExtensionSettings = { + tools: { + autoAccept: false, + }, + }; + + const result = validator.validate(settings); + expect(result.valid).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); + + describe('unlisted settings', () => { + it('should reject settings not in allowlist', () => { + const settings = { + context: { + unknownSetting: 'value', + }, + } as unknown as ExtensionSettings; + + const result = validator.validate(settings); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + expect(result.errors.some((e) => e.includes('unknownSetting'))).toBe( + true, + ); + expect(result.errors.some((e) => e.includes('allowlist'))).toBe(true); + }); + + it('should reject unknown top-level keys', () => { + const settings = { + unknownCategory: { + setting: 'value', + }, + } as unknown as ExtensionSettings; + + const result = validator.validate(settings); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + expect( + result.errors.some( + (e) => e.includes('unknownCategory') || e.includes('setting'), + ), + ).toBe(true); + expect(result.errors.some((e) => e.includes('allowlist'))).toBe(true); + }); + }); + + describe('isArrayAppendSetting', () => { + it('should identify array append settings', () => { + expect(validator.isArrayAppendSetting('context.includeDirectories')).toBe( + true, + ); + expect(validator.isArrayAppendSetting('context.fileName')).toBe(true); + expect(validator.isArrayAppendSetting('tools.exclude')).toBe(true); + expect(validator.isArrayAppendSetting('tools.allowed')).toBe(true); + expect(validator.isArrayAppendSetting('ui.customWittyPhrases')).toBe( + true, + ); + }); + + it('should return false for non-append settings', () => { + expect( + validator.isArrayAppendSetting( + 'context.loadMemoryFromIncludeDirectories', + ), + ).toBe(false); + expect(validator.isArrayAppendSetting('tools.autoAccept')).toBe(false); + expect(validator.isArrayAppendSetting('ui.hideTips')).toBe(false); + }); + }); + + describe('multiple errors', () => { + it('should report all validation errors', () => { + const settings = { + security: { + auth: true, + }, + tools: { + autoAccept: true, + sandbox: true, + }, + unknownKey: 'value', + } as unknown as ExtensionSettings; + + const result = validator.validate(settings); + expect(result.valid).toBe(false); + expect(result.errors.length).toBeGreaterThan(1); + }); + }); +}); diff --git a/packages/cli/src/config/extension-settings-validator.ts b/packages/cli/src/config/extension-settings-validator.ts new file mode 100644 index 00000000000..754005acebc --- /dev/null +++ b/packages/cli/src/config/extension-settings-validator.ts @@ -0,0 +1,272 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ExtensionSettings } from './extension-settings.js'; + +export interface SettingsAllowlist { + allowed: string[]; + forbidden: string[]; + arrayAppendOnly: string[]; +} + +export class ExtensionSettingsValidator { + private readonly allowlist: SettingsAllowlist = { + allowed: [ + // General settings + 'general.preferredEditor', + 'general.vimMode', + 'general.disableAutoUpdate', + 'general.disableUpdateNag', + 'general.checkpointing.enabled', + 'general.enablePromptCompletion', + 'general.retryFetchErrors', + 'general.debugKeystrokeLogging', + 'general.sessionRetention.enabled', + 'general.sessionRetention.maxAge', + 'general.sessionRetention.maxCount', + 'general.sessionRetention.minRetention', + + // Output settings + 'output.format', + + // UI settings + 'ui.theme', + 'ui.customThemes', + 'ui.hideWindowTitle', + 'ui.showStatusInTitle', + 'ui.hideTips', + 'ui.hideBanner', + 'ui.hideContextSummary', + 'ui.footer.hideCWD', + 'ui.footer.hideSandboxStatus', + 'ui.footer.hideModelInfo', + 'ui.footer.hideContextPercentage', + 'ui.hideFooter', + 'ui.showMemoryUsage', + 'ui.showLineNumbers', + 'ui.showCitations', + 'ui.useFullWidth', + 'ui.useAlternateBuffer', + 'ui.customWittyPhrases', + 'ui.accessibility.disableLoadingPhrases', + 'ui.accessibility.screenReader', + + // IDE settings + 'ide.enabled', + 'ide.hasSeenNudge', + + // Context settings + 'context.fileName', + 'context.importFormat', + 'context.discoveryMaxDirs', + 'context.includeDirectories', + 'context.loadMemoryFromIncludeDirectories', + 'context.fileFiltering.respectGitIgnore', + 'context.fileFiltering.respectGeminiIgnore', + 'context.fileFiltering.enableRecursiveFileSearch', + 'context.fileFiltering.disableFuzzySearch', + + // Tool settings + 'tools.shell.enableInteractiveShell', + 'tools.shell.pager', + 'tools.shell.showColor', + 'tools.autoAccept', + 'tools.core', + 'tools.allowed', + 'tools.exclude', + 'tools.discoveryCommand', + 'tools.callCommand', + 'tools.useRipgrep', + 'tools.enableToolOutputTruncation', + 'tools.truncateToolOutputThreshold', + 'tools.truncateToolOutputLines', + 'tools.enableMessageBusIntegration', + 'tools.enableHooks', + + // Model settings + 'model.name', + 'model.maxSessionTurns', + 'model.summarizeToolOutput', + 'model.compressionThreshold', + 'model.skipNextSpeakerCheck', + + // Model configs + 'modelConfigs.aliases', + 'modelConfigs.overrides', + + // MCP settings + 'mcp.serverCommand', + 'mcp.allowed', + 'mcp.excluded', + + // Top-level feature flags + 'useSmartEdit', + 'useWriteTodos', + + // Advanced settings + 'advanced.autoConfigureMemory', + 'advanced.dnsResolutionOrder', + 'advanced.excludedEnvVars', + 'advanced.bugCommand', + + // Experimental settings + 'experimental.extensionManagement', + 'experimental.extensionReloading', + 'experimental.useModelRouter', + 'experimental.codebaseInvestigatorSettings.enabled', + 'experimental.codebaseInvestigatorSettings.maxNumTurns', + 'experimental.codebaseInvestigatorSettings.maxTimeMinutes', + 'experimental.codebaseInvestigatorSettings.thinkingBudget', + 'experimental.codebaseInvestigatorSettings.model', + + // Hooks - allow any hook configuration + 'hooks', + + // MCP Servers - allow server configs but not trust field + 'mcpServers', + ], + + forbidden: [ + // Security settings - never allow + 'security', + 'security.auth', + 'security.folderTrust', + 'security.disableYoloMode', + + // Privacy/telemetry - never allow + 'telemetry', + 'privacy', + 'privacy.usageStatisticsEnabled', + + // Dangerous tool settings + 'tools.sandbox', // Don't allow extensions to modify sandbox settings + 'mcpServers.*.trust', // Don't allow extensions to bypass trust confirmation + ], + + arrayAppendOnly: [ + 'context.includeDirectories', + 'context.fileName', + 'tools.exclude', + 'tools.allowed', + 'tools.core', + 'ui.customWittyPhrases', + 'mcp.allowed', + 'mcp.excluded', + 'advanced.excludedEnvVars', + 'modelConfigs.overrides', + ], + }; + + validate(settings: ExtensionSettings): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + this.validateObject( + settings as Record, + '', + errors, + warnings, + ); + + return { + valid: errors.length === 0, + errors, + warnings, + }; + } + + private validateObject( + obj: Record, + path: string, + errors: string[], + warnings: string[], + ): void { + for (const [key, value] of Object.entries(obj)) { + const currentPath = path ? `${path}.${key}` : key; + + if (this.isForbidden(currentPath)) { + errors.push( + `Setting "${currentPath}" is forbidden for security reasons. ` + + `Extensions cannot modify authentication, privacy, or security settings.`, + ); + continue; + } + + if (!this.isAllowed(currentPath)) { + errors.push( + `Setting "${currentPath}" is not in the allowlist. ` + + `See documentation for allowed settings.`, + ); + continue; + } + + if (currentPath === 'tools.autoAccept' && value === true) { + errors.push( + `Setting "tools.autoAccept" to true is not allowed for security. ` + + `Extensions can only set it to false.`, + ); + continue; + } + + if ( + typeof value === 'object' && + !Array.isArray(value) && + value !== null + ) { + this.validateObject( + value as Record, + currentPath, + errors, + warnings, + ); + } + } + } + + private isForbidden(path: string): boolean { + return this.allowlist.forbidden.some((forbidden) => { + if (forbidden.includes('*')) { + // Handle patterns like "mcpServers.*.trust" + const pattern = forbidden.replace(/\*/g, '[^.]+'); + const regex = new RegExp(`^${pattern}$`); + return regex.test(path); + } + return path === forbidden || path.startsWith(forbidden + '.'); + }); + } + + private isAllowed(path: string): boolean { + return this.allowlist.allowed.some((allowed) => { + if (path === allowed) { + return true; // Exact match + } + // Check if this is a parent of an allowed setting + if (allowed.startsWith(path + '.')) { + return true; + } + // Special handling for paths that allow arbitrary nested keys + // e.g., "hooks" allows "hooks.beforeTool", "hooks.afterModel", etc. + // e.g., "mcpServers" allows "mcpServers.myServer", "mcpServers.myServer.command", etc. + if (allowed === 'hooks' && path.startsWith('hooks.')) { + return true; + } + if (allowed === 'mcpServers' && path.startsWith('mcpServers.')) { + return true; + } + return false; + }); + } + + isArrayAppendSetting(path: string): boolean { + return this.allowlist.arrayAppendOnly.includes(path); + } +} + +export interface ValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} diff --git a/packages/cli/src/config/extension-settings.ts b/packages/cli/src/config/extension-settings.ts new file mode 100644 index 00000000000..d47148234b1 --- /dev/null +++ b/packages/cli/src/config/extension-settings.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface ContextSettings { + fileName?: string | string[]; + importFormat?: string; + discoveryMaxDirs?: number; + includeDirectories?: string[]; + loadMemoryFromIncludeDirectories?: boolean; + fileFiltering?: { + respectGitIgnore?: boolean; + respectGeminiIgnore?: boolean; + enableRecursiveFileSearch?: boolean; + disableFuzzySearch?: boolean; + }; +} + +export interface ToolSettings { + shell?: { + enableInteractiveShell?: boolean; + pager?: string; + showColor?: boolean; + }; + autoAccept?: boolean; // Only false allowed, not true + core?: string[]; + allowed?: string[]; + exclude?: string[]; + discoveryCommand?: string; + callCommand?: string; + useRipgrep?: boolean; + enableToolOutputTruncation?: boolean; + truncateToolOutputThreshold?: number; + truncateToolOutputLines?: number; + enableMessageBusIntegration?: boolean; + enableHooks?: boolean; +} + +export interface UISettings { + theme?: string; + customThemes?: Record; + hideWindowTitle?: boolean; + showStatusInTitle?: boolean; + hideTips?: boolean; + hideBanner?: boolean; + hideContextSummary?: boolean; + footer?: { + hideCWD?: boolean; + hideSandboxStatus?: boolean; + hideModelInfo?: boolean; + hideContextPercentage?: boolean; + }; + hideFooter?: boolean; + showMemoryUsage?: boolean; + showLineNumbers?: boolean; + showCitations?: boolean; + useFullWidth?: boolean; + useAlternateBuffer?: boolean; + customWittyPhrases?: string[]; + accessibility?: { + disableLoadingPhrases?: boolean; + screenReader?: boolean; + }; +} + +export interface ModelSettings { + name?: string; + maxSessionTurns?: number; + summarizeToolOutput?: Record; + compressionThreshold?: number; + skipNextSpeakerCheck?: boolean; +} + +export interface GeneralSettings { + preferredEditor?: string; + vimMode?: boolean; + disableAutoUpdate?: boolean; + disableUpdateNag?: boolean; + checkpointing?: { + enabled?: boolean; + }; + enablePromptCompletion?: boolean; + retryFetchErrors?: boolean; + debugKeystrokeLogging?: boolean; + sessionRetention?: { + enabled?: boolean; + maxAge?: string; + maxCount?: number; + minRetention?: string; + }; +} + +export interface OutputSettings { + format?: 'text' | 'json'; +} + +export interface IDESettings { + enabled?: boolean; + hasSeenNudge?: boolean; +} + +export interface MCPSettings { + serverCommand?: string; + allowed?: string[]; + excluded?: string[]; +} + +export interface ModelConfigsSettings { + aliases?: Record; + overrides?: unknown[]; +} + +export interface AdvancedSettings { + autoConfigureMemory?: boolean; + dnsResolutionOrder?: string; + excludedEnvVars?: string[]; + bugCommand?: Record; +} + +export interface ExperimentalSettings { + extensionManagement?: boolean; + extensionReloading?: boolean; + useModelRouter?: boolean; + codebaseInvestigatorSettings?: { + enabled?: boolean; + maxNumTurns?: number; + maxTimeMinutes?: number; + thinkingBudget?: number; + model?: string; + }; +} + +export interface HooksSettings { + [key: string]: unknown; +} + +export interface ExtensionSettings { + general?: GeneralSettings; + output?: OutputSettings; + ui?: UISettings; + ide?: IDESettings; + context?: ContextSettings; + tools?: ToolSettings; + model?: ModelSettings; + modelConfigs?: ModelConfigsSettings; + mcp?: MCPSettings; + useSmartEdit?: boolean; + useWriteTodos?: boolean; + advanced?: AdvancedSettings; + experimental?: ExperimentalSettings; + hooks?: HooksSettings; + mcpServers?: Record; +} diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index c762dd3295b..3f02372f02c 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -1083,7 +1083,7 @@ This extension will run the following MCP servers: ).resolves.toMatchObject({ name: 'my-local-extension' }); }); - it('should cancel installation if user declines prompt for local extension with mcp servers', async () => { + it('should cancel installation if user declines prompt for local s...', async () => { const sourceExtDir = createExtension({ extensionsDir: tempHomeDir, name: 'my-local-extension', @@ -1095,7 +1095,13 @@ This extension will run the following MCP servers: }, }, }); - mockRequestConsent.mockResolvedValue(false); + // Mock consent to return false for the generic install prompt + mockRequestConsent.mockImplementation(async (consentString) => { + if (consentString.includes(INSTALL_WARNING_MESSAGE)) { + return false; // Decline generic install consent + } + return true; // Accept other consents (e.g., settings) + }); await extensionManager.loadExtensions(); await expect( extensionManager.installOrUpdateExtension({ @@ -1132,7 +1138,7 @@ This extension will run the following MCP servers: fs.rmSync(targetExtDir, { recursive: true, force: true }); }); - it('should ignore consent flow if not required', async () => { + it('should only request consent on initial install', async () => { const sourceExtDir = createExtension({ extensionsDir: tempHomeDir, name: 'my-local-extension', @@ -1143,6 +1149,10 @@ This extension will run the following MCP servers: args: ['server.js'], }, }, + // Add some new settings to trigger the settings consent flow + general: { + vimMode: true, + }, }); await extensionManager.loadExtensions(); @@ -1151,7 +1161,10 @@ This extension will run the following MCP servers: source: sourceExtDir, type: 'local', }); - expect(mockRequestConsent).toHaveBeenCalledOnce(); + // Called once for generic warning, once for settings + expect(mockRequestConsent).toHaveBeenCalledTimes(2); + + vi.clearAllMocks(); // Clear mocks before update // Now update it without changing anything. await expect( @@ -1162,8 +1175,8 @@ This extension will run the following MCP servers: ), ).resolves.toMatchObject({ name: 'my-local-extension' }); - // Still only called once - expect(mockRequestConsent).toHaveBeenCalledOnce(); + // Should not be called on update + expect(mockRequestConsent).not.toHaveBeenCalled(); }); it('should prompt for settings if promptForSettings', async () => { diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index bafaba59a8e..903657f91fb 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -12,6 +12,20 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { INSTALL_METADATA_FILENAME } from './extensions/variables.js'; import type { ExtensionSetting } from './extensions/extensionSettings.js'; +import type { + AdvancedSettings, + ContextSettings, + ExperimentalSettings, + GeneralSettings, + HooksSettings, + IDESettings, + MCPSettings, + ModelConfigsSettings, + ModelSettings, + OutputSettings, + ToolSettings, + UISettings, +} from './extension-settings.js'; /** * Extension definition as written to disk in gemini-extension.json files. @@ -27,6 +41,21 @@ export interface ExtensionConfig { contextFileName?: string | string[]; excludeTools?: string[]; settings?: ExtensionSetting[]; + // Extension-contributed settings + general?: GeneralSettings; + output?: OutputSettings; + ui?: UISettings; + ide?: IDESettings; + context?: ContextSettings; + tools?: ToolSettings; + model?: ModelSettings; + modelConfigs?: ModelConfigsSettings; + mcp?: MCPSettings; + useSmartEdit?: boolean; + useWriteTodos?: boolean; + advanced?: AdvancedSettings; + experimental?: ExperimentalSettings; + hooks?: HooksSettings; } export interface ExtensionUpdateInfo { diff --git a/packages/cli/src/config/extensions/variables.ts b/packages/cli/src/config/extensions/variables.ts index 78506a9738c..bd051caee51 100644 --- a/packages/cli/src/config/extensions/variables.ts +++ b/packages/cli/src/config/extensions/variables.ts @@ -10,6 +10,7 @@ import { GEMINI_DIR } from '@google/gemini-cli-core'; export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json'; +export const EXTENSION_CONFIG_SETTINGS_FILENAME = 'extension-settings.json'; export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json'; export const EXTENSION_SETTINGS_FILENAME = '.env'; diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index 3a3295beccf..717526adb52 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -33,6 +33,252 @@ import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { customDeepMerge, type MergeableObject } from '../utils/deepMerge.js'; import { updateSettingsFilePreservingFormat } from '../utils/commentJson.js'; import type { ExtensionManager } from './extension-manager.js'; +import { ExtensionSettingsValidator } from './extension-settings-validator.js'; +import type { ExtensionSettings } from './extension-settings.js'; +import { ExtensionEnablementManager } from './extensions/extensionEnablement.js'; + +interface ConflictTracker { + conflicts: Map; + ownership: Map; +} + +function deepMergeWithArrayAppend( + target: Record, + source: Record, + path = '', + tracker?: ConflictTracker, + extensionName?: string, +): unknown { + const validator = new ExtensionSettingsValidator(); + + if (Array.isArray(target) && Array.isArray(source)) { + if (validator.isArrayAppendSetting(path)) { + return [...target, ...source].filter( + (item, index, self) => self.indexOf(item) === index, + ); + } else { + return source; + } + } + + if ( + typeof target === 'object' && + typeof source === 'object' && + target !== null && + source !== null && + !Array.isArray(target) && + !Array.isArray(source) + ) { + const merged = { ...target }; + + for (const [key, value] of Object.entries(source)) { + const currentPath = path ? `${path}.${key}` : key; + + if (key in merged) { + merged[key] = deepMergeWithArrayAppend( + merged[key] as Record, + value as Record, + currentPath, + tracker, + extensionName, + ); + } else { + merged[key] = value; + if (tracker && extensionName) { + tracker.ownership.set(currentPath, extensionName); + } + } + } + + return merged; + } + + if (tracker && extensionName && path) { + const previousOwner = tracker.ownership.get(path); + if (previousOwner && previousOwner !== extensionName) { + const existing = tracker.conflicts.get(path); + if (existing) { + if (!existing.extensions.includes(extensionName)) { + existing.extensions.push(extensionName); + } + } else { + tracker.conflicts.set(path, { + extensions: [previousOwner, extensionName], + value: source, + }); + } + } + tracker.ownership.set(path, extensionName); + } + + return source; +} + +function resolveExtensionVariables( + settings: unknown, + extensionPath: string, + workspacePath?: string, +): unknown { + if (typeof settings === 'string') { + return settings + .replace(/\${extensionPath}/g, extensionPath) + .replace(/\${workspacePath}/g, workspacePath || process.cwd()) + .replace(/\${\/}/g, path.sep) + .replace(/\${pathSeparator}/g, path.sep); + } + + if (Array.isArray(settings)) { + return settings.map((item) => + resolveExtensionVariables(item, extensionPath, workspacePath), + ); + } + + if (typeof settings === 'object' && settings !== null) { + const resolved: Record = {}; + for (const [key, value] of Object.entries(settings)) { + resolved[key] = resolveExtensionVariables( + value, + extensionPath, + workspacePath, + ); + } + return resolved; + } + + return settings; +} + +function parseEnabledExtensionsFromArgv(): string[] | undefined { + // Handle test environments where process.argv might not be set + if (!process.argv || !Array.isArray(process.argv)) { + return undefined; + } + + const args = process.argv.slice(2); + const extensions: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === '-e' || arg === '--extensions') { + if (i + 1 < args.length) { + extensions.push(...args[i + 1].split(',').map((e) => e.trim())); + i++; + } + } else if (arg.startsWith('--extensions=')) { + const value = arg.substring('--extensions='.length); + extensions.push(...value.split(',').map((e) => e.trim())); + } + } + + return extensions.length > 0 ? extensions : undefined; +} + +export function loadExtensionSettings(): Partial { + const extensionsDir = path.join(homedir(), '.gemini', 'extensions'); + + if (!fs.existsSync(extensionsDir)) { + return {}; + } + + let extensionNames: string[]; + try { + extensionNames = fs + .readdirSync(extensionsDir) + .filter((name) => { + const extPath = path.join(extensionsDir, name); + try { + return fs.statSync(extPath).isDirectory(); + } catch { + return false; + } + }) + .sort(); + } catch { + // Directory doesn't exist or can't be read + return {}; + } + + const enabledExtensionOverrides = parseEnabledExtensionsFromArgv(); + + const enablementManager = new ExtensionEnablementManager( + enabledExtensionOverrides, + ); + const currentPath = process.cwd(); + + let mergedSettings: Partial = {}; + const tracker: ConflictTracker = { + conflicts: new Map(), + ownership: new Map(), + }; + + for (const extensionName of extensionNames) { + if (!enablementManager.isEnabled(extensionName, currentPath)) { + continue; + } + + const settingsPath = path.join( + extensionsDir, + extensionName, + 'extension-settings.json', + ); + + if (!fs.existsSync(settingsPath)) { + continue; + } + + try { + const settingsContent = fs.readFileSync(settingsPath, 'utf-8'); + const extensionSettings: ExtensionSettings = JSON.parse(settingsContent); + + const validator = new ExtensionSettingsValidator(); + const validationResult = validator.validate(extensionSettings); + if (!validationResult.valid) { + console.warn( + `Extension "${extensionName}" has invalid settings (allowlist may have changed):`, + ); + for (const error of validationResult.errors) { + console.warn(` - ${error}`); + } + continue; + } + + const extensionPath = path.join(extensionsDir, extensionName); + const resolvedSettings = resolveExtensionVariables( + extensionSettings, + extensionPath, + currentPath, + ); + + mergedSettings = deepMergeWithArrayAppend( + mergedSettings, + resolvedSettings as Record, + '', + tracker, + extensionName, + ) as Partial; + } catch (error) { + console.warn( + `Failed to load settings from extension "${extensionName}":`, + error, + ); + } + } + + // Warn about scalar conflicts + if (tracker.conflicts.size > 0) { + console.warn('\nExtension settings conflicts detected:'); + for (const [settingPath, conflict] of tracker.conflicts.entries()) { + const winner = conflict.extensions[conflict.extensions.length - 1]; + console.warn( + ` Setting "${settingPath}" set by multiple extensions: ${conflict.extensions.join(', ')}`, + ); + console.warn(` Using value from "${winner}" (alphabetically last)`); + } + console.warn(''); + } + + return mergedSettings; +} function getMergeStrategyForPath(path: string[]): MergeStrategy | undefined { let current: SettingDefinition | undefined = undefined; @@ -402,6 +648,7 @@ function mergeSettings( system: Settings, systemDefaults: Settings, user: Settings, + extensions: Settings, workspace: Settings, isTrusted: boolean, ): Settings { @@ -411,13 +658,15 @@ function mergeSettings( // single values): // 1. System Defaults // 2. User Settings - // 3. Workspace Settings - // 4. System Settings (as overrides) + // 3. Extension Settings + // 4. Workspace Settings + // 5. System Settings (as overrides) return customDeepMerge( getMergeStrategyForPath, {}, // Start with an empty object systemDefaults, user, + extensions, safeWorkspace, system, ) as Settings; @@ -459,6 +708,7 @@ export class LoadedSettings { this.system.settings, this.systemDefaults.settings, this.user.settings, + loadExtensionSettings() as Settings, this.workspace.settings, this.isTrusted, ); @@ -731,6 +981,7 @@ export function loadSettings( systemSettings, systemDefaultSettings, userSettings, + loadExtensionSettings() as Settings, workspaceSettings, isTrusted, ); diff --git a/packages/cli/src/test-utils/createExtension.ts b/packages/cli/src/test-utils/createExtension.ts index f7ad425f064..302f96105aa 100644 --- a/packages/cli/src/test-utils/createExtension.ts +++ b/packages/cli/src/test-utils/createExtension.ts @@ -15,6 +15,7 @@ import { INSTALL_METADATA_FILENAME, } from '../config/extensions/variables.js'; import type { ExtensionSetting } from '../config/extensions/extensionSettings.js'; +import type { ExtensionSettings } from '../config/extension-settings.js'; export function createExtension({ extensionsDir = 'extensions-dir', @@ -25,12 +26,29 @@ export function createExtension({ mcpServers = {} as Record, installMetadata = undefined as ExtensionInstallMetadata | undefined, settings = undefined as ExtensionSetting[] | undefined, -} = {}): string { + ...extensionSettings +}: { + extensionsDir?: string; + name?: string; + version?: string; + addContextFile?: boolean; + contextFileName?: string; + mcpServers?: Record; + installMetadata?: ExtensionInstallMetadata; + settings?: ExtensionSetting[]; +} & Partial = {}): string { const extDir = path.join(extensionsDir, name); fs.mkdirSync(extDir, { recursive: true }); fs.writeFileSync( path.join(extDir, EXTENSIONS_CONFIG_FILENAME), - JSON.stringify({ name, version, contextFileName, mcpServers, settings }), + JSON.stringify({ + name, + version, + contextFileName, + mcpServers, + settings, + ...extensionSettings, + }), ); if (addContextFile) { diff --git a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts index d317170c184..53cb5c81a99 100644 --- a/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts +++ b/packages/cli/src/ui/hooks/usePermissionsModifyTrust.test.ts @@ -27,9 +27,14 @@ const mockedIsWorkspaceTrusted = vi.hoisted(() => vi.fn()); const mockedUseSettings = vi.hoisted(() => vi.fn()); // Mock modules -vi.mock('node:process', () => ({ - cwd: mockedCwd, -})); +vi.mock('node:process', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + cwd: mockedCwd, + argv: [], // Mock argv to be an empty array + }; +}); vi.mock('../../config/trustedFolders.js', () => ({ loadTrustedFolders: mockedLoadTrustedFolders,