Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions docs/extensions/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.,
`<home>/.gemini/extensions/my-context-extension/extension-settings.json`) with
the following content:

```json
{
"context": {
"includeDirectories": ["src/my-feature", "docs/my-feature"]
}
}
```
103 changes: 103 additions & 0 deletions packages/cli/src/config/extension-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('node:os')>();
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),
);
});
});
91 changes: 91 additions & 0 deletions packages/cli/src/config/extension-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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[];
Expand Down Expand Up @@ -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' ||
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -671,6 +688,80 @@ export class ExtensionManager extends ExtensionLoader {
}
}

const extensionSettingKeys: Array<keyof ExtensionSettings> = [
'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<string, unknown>)[key] !== undefined
) {
(settings as unknown as Record<string, unknown>)[key] = (
config as unknown as Record<string, unknown>
)[key];
}
}
return settings;
}

async function maybeValidateAndPromptForSettingContributions(
config: ExtensionConfig,
requestConsent: (consent: string) => Promise<boolean>,
previousExtensionConfig?: ExtensionConfig,
): Promise<ExtensionSettings | null> {
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;
Expand Down
Loading