Compare commits

..

12 Commits

Author SHA1 Message Date
Mahima Shanware 9be5347b3e feat(extensions): implement plan directory merging logic and add tests 2026-02-25 21:07:28 +00:00
Mahima Shanware 2c82d18c9f feat(extensions): add plan directory support to gemini-extension.json 2026-02-25 20:58:43 +00:00
Christine Betts 6f11d7eaf7 Address comments 2026-02-25 12:59:45 -05:00
Christine Betts e599bf8ca3 Address comments 2026-02-25 12:35:59 -05:00
Christine Betts 5ddaccde67 address comment 2026-02-24 15:41:40 -05:00
Christine Betts 06979f7e22 merge 2026-02-24 14:30:35 -05:00
Christine Betts 9851e0c024 address comments 2026-02-24 14:29:45 -05:00
Christine Betts 29ab667755 fix security issue 2026-02-23 17:25:42 -05:00
Christine Betts dd58d49aac fix docs 2026-02-23 17:23:06 -05:00
Christine Betts 1a973b9a3b Update extension tier 2026-02-23 16:46:46 -05:00
Christine Betts 8f83f1fd99 Update tests 2026-02-23 14:01:55 -05:00
Christine Betts 41185d15e7 Add support for policy engine in extensions 2026-02-23 12:58:44 -05:00
91 changed files with 1604 additions and 3249 deletions
@@ -20,7 +20,8 @@ async function run(cmd) {
stdio: ['pipe', 'pipe', 'ignore'],
});
return stdout.trim();
} catch {
} catch (_e) {
// eslint-disable-line @typescript-eslint/no-unused-vars
return null;
}
}
+7 -18
View File
@@ -48,24 +48,6 @@ jobs:
const repo = context.repo.repo;
const MAX_ISSUES_ASSIGNED = 3;
const issue = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
const hasHelpWantedLabel = issue.data.labels.some(label => label.name === 'help wanted');
if (!hasHelpWantedLabel) {
await github.rest.issues.createComment({
owner: owner,
repo: repo,
issue_number: issueNumber,
body: `👋 @${commenter}, thanks for your interest in this issue! We're reserving self-assignment for issues that have been marked with the \`help wanted\` label. Feel free to check out our list of [issues that need attention](https://github.com/google-gemini/gemini-cli/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22).`
});
return;
}
// Search for open issues already assigned to the commenter in this repo
const { data: assignedIssues } = await github.rest.search.issuesAndPullRequests({
q: `is:issue repo:${owner}/${repo} assignee:${commenter} is:open`,
@@ -82,6 +64,13 @@ jobs:
return; // exit
}
// Check if the issue is already assigned
const issue = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
if (issue.data.assignees.length > 0) {
// Comment that it's already assigned
await github.rest.issues.createComment({
+1 -1
View File
@@ -382,7 +382,7 @@ See the [Uninstall Guide](docs/cli/uninstall.md) for removal instructions.
## 📄 Legal
- **License**: [Apache License 2.0](LICENSE)
- **Terms of Service**: [Terms & Privacy](./docs/resources/tos-privacy.md)
- **Terms of Service**: [Terms & Privacy](./docs/tos-privacy.md)
- **Security**: [Security Policy](SECURITY.md)
---
-29
View File
@@ -27,7 +27,6 @@ implementation. It allows you to:
- [Example: Allow git commands in Plan Mode](#example-allow-git-commands-in-plan-mode)
- [Example: Enable research subagents in Plan Mode](#example-enable-research-subagents-in-plan-mode)
- [Custom Plan Directory and Policies](#custom-plan-directory-and-policies)
- [Automatic Model Routing](#automatic-model-routing)
## Enabling Plan Mode
@@ -243,32 +242,6 @@ modes = ["plan"]
argsPattern = "\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\""
```
## Automatic Model Routing
When using an [**auto model**], Gemini CLI automatically optimizes [**model
routing**] based on the current phase of your task:
1. **Planning Phase:** While in Plan Mode, the CLI routes requests to a
high-reasoning **Pro** model to ensure robust architectural decisions and
high-quality plans.
2. **Implementation Phase:** Once a plan is approved and you exit Plan Mode,
the CLI detects the existence of the approved plan and automatically
switches to a high-speed **Flash** model. This provides a faster, more
responsive experience during the implementation of the plan.
This behavior is enabled by default to provide the best balance of quality and
performance. You can disable this automatic switching in your settings:
```json
{
"general": {
"plan": {
"modelRouting": false
}
}
}
```
[`list_directory`]: /docs/tools/file-system.md#1-list_directory-readfolder
[`read_file`]: /docs/tools/file-system.md#2-read_file-readfile
[`grep_search`]: /docs/tools/file-system.md#5-grep_search-searchtext
@@ -286,5 +259,3 @@ performance. You can disable this automatic switching in your settings:
[YOLO mode]: /docs/reference/configuration.md#command-line-arguments
[`plan.toml`]:
https://github.com/google-gemini/gemini-cli/blob/main/packages/core/src/policy/policies/plan.toml
[auto model]: /docs/reference/configuration.md#model-settings
[model routing]: /docs/cli/telemetry.md#model-routing
-1
View File
@@ -29,7 +29,6 @@ they appear in the UI.
| Enable Auto Update | `general.enableAutoUpdate` | Enable automatic updates. | `true` |
| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. Currently macOS only. | `false` |
| Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. | `undefined` |
| Plan Model Routing | `general.plan.modelRouting` | Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase. | `true` |
| Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` |
| Debug Keystroke Logging | `general.debugKeystrokeLogging` | Enable debug logging of keystrokes to the console. | `false` |
| Enable Session Cleanup | `general.sessionRetention.enabled` | Enable automatic session cleanup | `false` |
-3
View File
@@ -487,7 +487,6 @@ Captures Gemini API requests, responses, and errors.
- `reasoning` (string, optional)
- `failed` (boolean)
- `error_message` (string, optional)
- `approval_mode` (string)
#### Chat and streaming
@@ -712,14 +711,12 @@ Routing latency/failures and slash-command selections.
- **Attributes**:
- `routing.decision_model` (string)
- `routing.decision_source` (string)
- `routing.approval_mode` (string)
- `gemini_cli.model_routing.failure.count` (Counter, Int): Counts model routing
failures.
- **Attributes**:
- `routing.decision_source` (string)
- `routing.error_message` (string)
- `routing.approval_mode` (string)
##### Agent runs
+36
View File
@@ -227,6 +227,42 @@ skill definitions in a `skills/` directory. For example,
Provide [sub-agents](../core/subagents.md) that users can delegate tasks to. Add
agent definition files (`.md`) to an `agents/` directory in your extension root.
### <a id="policy-engine"></a>Policy Engine
Extensions can contribute policy rules and safety checkers to the Gemini CLI
[Policy Engine](../reference/policy-engine.md). These rules are defined in
`.toml` files and take effect when the extension is activated.
To add policies, create a `policies/` directory in your extension's root and
place your `.toml` policy files inside it. Gemini CLI automatically loads all
`.toml` files from this directory.
Rules contributed by extensions run in the **Workspace Tier** (Tier 2),
alongside workspace-defined policies. This tier has higher priority than the
default rules but lower priority than user or admin policies.
> **Warning:** For security, Gemini CLI ignores any `allow` decisions or `yolo`
> mode configurations in extension policies. This ensures that an extension
> cannot automatically approve tool calls or bypass security measures without
> your confirmation.
**Example `policies.toml`**
```toml
[[rule]]
toolName = "my_server__dangerous_tool"
decision = "ask_user"
priority = 100
[[safety_checker]]
toolName = "my_server__write_data"
priority = 200
[safety_checker.checker]
type = "in-process"
name = "allowed-path"
required_context = ["environment"]
```
### Themes
Extensions can provide custom themes to personalize the CLI UI. Themes are
+21 -20
View File
@@ -21,10 +21,8 @@ Jump in to Gemini CLI.
personal and enterprise accounts.
- **[Examples](./get-started/examples.md):** Practical examples of Gemini CLI in
action.
- **[CLI cheatsheet](./cli/cli-reference.md):** A quick reference for common
- **[Cheatsheet](./cli/cli-reference.md):** A quick reference for common
commands and options.
- **[Gemini 3 on Gemini CLI](./get-started/gemini-3.md):** Learn about Gemini 3
support in Gemini CLI.
## Use Gemini CLI
@@ -52,29 +50,33 @@ User-focused guides and tutorials for daily development workflows.
Technical documentation for each capability of Gemini CLI.
- **[Extensions](./extensions/index.md):** Extend Gemini CLI with new tools and
capabilities.
- **[Agent Skills](./cli/skills.md):** Use specialized agents for specific
tasks.
- **[Activate skill (tool)](./tools/activate-skill.md):** Internal mechanism for
loading expert procedures.
- **[Ask user (tool)](./tools/ask-user.md):** Internal dialog system for
clarification.
- **[Checkpointing](./cli/checkpointing.md):** Automatic session snapshots.
- **[File system (tool)](./tools/file-system.md):** Technical details for local
file operations.
- **[Headless mode](./cli/headless.md):** Programmatic and scripting interface.
- **[Hooks](./hooks/index.md):** Customize Gemini CLI behavior with scripts.
- **[IDE integration](./ide-integration/index.md):** Integrate Gemini CLI with
your favorite IDE.
- **[MCP servers](./tools/mcp-server.md):** Connect to and use remote agents.
- **[Internal documentation (tool)](./tools/internal-docs.md):** Technical
lookup for CLI features.
- **[Memory (tool)](./tools/memory.md):** Storage details for persistent facts.
- **[Model routing](./cli/model-routing.md):** Automatic fallback resilience.
- **[Model selection](./cli/model.md):** Choose the best model for your needs.
- **[Plan mode 🔬](./cli/plan-mode.md):** Use a safe, read-only mode for
- **[Plan mode 🧪](./cli/plan-mode.md):** Use a safe, read-only mode for
planning complex changes.
- **[Subagents 🔬](./core/subagents.md):** Using specialized agents for specific
- **[Subagents 🧪](./core/subagents.md):** Using specialized agents for specific
tasks.
- **[Remote subagents 🔬](./core/remote-agents.md):** Connecting to and using
- **[Remote subagents 🧪](./core/remote-agents.md):** Connecting to and using
remote agents.
- **[Rewind](./cli/rewind.md):** Rewind and replay sessions.
- **[Sandboxing](./cli/sandbox.md):** Isolate tool execution.
- **[Settings](./cli/settings.md):** Full configuration reference.
- **[Shell (tool)](./tools/shell.md):** Detailed system execution parameters.
- **[Telemetry](./cli/telemetry.md):** Usage and performance metric details.
- **[Todo (tool)](./tools/todos.md):** Progress tracking specification.
- **[Token caching](./cli/token-caching.md):** Performance optimization.
- **[Web fetch (tool)](./tools/web-fetch.md):** URL retrieval and extraction
details.
- **[Web search (tool)](./tools/web-search.md):** Google Search integration
technicals.
## Configuration
@@ -89,6 +91,7 @@ Settings and customization options for Gemini CLI.
parameters like temperature and thinking budget.
- **[Project context (GEMINI.md)](./cli/gemini-md.md):** Technical hierarchy of
context files.
- **[Settings](./cli/settings.md):** Full configuration reference.
- **[System prompt override](./cli/system-prompt.md):** Instruction replacement
logic.
- **[Themes](./cli/themes.md):** UI personalization technical guide.
@@ -116,13 +119,11 @@ Deep technical documentation and API specifications.
Support, release history, and legal information.
- **[FAQ](./resources/faq.md):** Answers to frequently asked questions.
- **[Changelogs](./changelogs/index.md):** Highlights and notable changes.
- **[Quota and pricing](./resources/quota-and-pricing.md):** Limits and billing
details.
- **[Terms and privacy](./resources/tos-privacy.md):** Official notices and
terms.
- **[Troubleshooting](./resources/troubleshooting.md):** Common issues and
solutions.
- **[Uninstall](./resources/uninstall.md):** How to uninstall Gemini CLI.
## Development
-6
View File
@@ -137,12 +137,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `undefined`
- **Requires restart:** Yes
- **`general.plan.modelRouting`** (boolean):
- **Description:** Automatically switch between Pro and Flash models based on
Plan Mode status. Uses Pro for the planning phase and Flash for the
implementation phase.
- **Default:** `true`
- **`general.retryFetchErrors`** (boolean):
- **Description:** Retry on "exception TypeError: fetch failed sending
request" errors.
+39 -32
View File
@@ -61,53 +61,31 @@
{
"label": "Features",
"items": [
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{
"label": "Authentication",
"slug": "docs/get-started/authentication"
},
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{
"label": "Extensions",
"collapsed": true,
"items": [
{
"label": "Overview",
"slug": "docs/extensions"
},
{
"label": "User guide: Install and manage",
"link": "/docs/extensions/#manage-extensions"
},
{
"label": "Developer guide: Build extensions",
"slug": "docs/extensions/writing-extensions"
},
{
"label": "Developer guide: Best practices",
"slug": "docs/extensions/best-practices"
},
{
"label": "Developer guide: Releasing",
"slug": "docs/extensions/releasing"
},
{
"label": "Developer guide: Reference",
"slug": "docs/extensions/reference"
}
]
"slug": "docs/extensions/index"
},
{ "label": "Agent Skills", "slug": "docs/cli/skills" },
{ "label": "Checkpointing", "slug": "docs/cli/checkpointing" },
{ "label": "Headless mode", "slug": "docs/cli/headless" },
{ "label": "Hooks", "slug": "docs/hooks" },
{ "label": "IDE integration", "slug": "docs/ide-integration" },
{ "label": "MCP servers", "slug": "docs/tools/mcp-server" },
{ "label": "Model routing", "slug": "docs/cli/model-routing" },
{ "label": "Model selection", "slug": "docs/cli/model" },
{ "label": "Plan mode", "badge": "🔬", "slug": "docs/cli/plan-mode" },
{ "label": "Plan mode", "badge": "🧪", "slug": "docs/cli/plan-mode" },
{
"label": "Subagents",
"badge": "🔬",
"badge": "🧪",
"slug": "docs/core/subagents"
},
{
"label": "Remote subagents",
"badge": "🔬",
"badge": "🧪",
"slug": "docs/core/remote-agents"
},
{ "label": "Rewind", "slug": "docs/cli/rewind" },
@@ -146,6 +124,35 @@
{ "label": "Trusted folders", "slug": "docs/cli/trusted-folders" }
]
},
{
"label": "Extensions",
"items": [
{
"label": "Overview",
"slug": "docs/extensions"
},
{
"label": "User guide: Install and manage",
"link": "/docs/extensions/#manage-extensions"
},
{
"label": "Developer guide: Build extensions",
"slug": "docs/extensions/writing-extensions"
},
{
"label": "Developer guide: Best practices",
"slug": "docs/extensions/best-practices"
},
{
"label": "Developer guide: Releasing",
"slug": "docs/extensions/releasing"
},
{
"label": "Developer guide: Reference",
"slug": "docs/extensions/reference"
}
]
},
{
"label": "Development",
"items": [
+2 -2
View File
@@ -82,7 +82,7 @@ const commonAliases = {
const cliConfig = {
...baseConfig,
banner: {
js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`,
js: `import { createRequire } from 'module'; const require = createRequire(import.meta.url); globalThis.__filename = require('url').fileURLToPath(import.meta.url); globalThis.__dirname = require('path').dirname(globalThis.__filename);`,
},
entryPoints: ['packages/cli/index.ts'],
outfile: 'bundle/gemini.js',
@@ -100,7 +100,7 @@ const cliConfig = {
const a2aServerConfig = {
...baseConfig,
banner: {
js: `const require = (await import('node:module')).createRequire(import.meta.url); globalThis.__filename = (await import('node:url')).fileURLToPath(import.meta.url); globalThis.__dirname = (await import('node:path')).dirname(globalThis.__filename);`,
js: `const require = (await import('module')).createRequire(import.meta.url); globalThis.__filename = require('url').fileURLToPath(import.meta.url); globalThis.__dirname = require('path').dirname(globalThis.__filename);`,
},
entryPoints: ['packages/a2a-server/src/http/server.ts'],
outfile: 'packages/a2a-server/dist/a2a-server.mjs',
+11 -1
View File
@@ -128,7 +128,17 @@ export default tseslint.config(
],
// Prevent async errors from bypassing catch handlers
'@typescript-eslint/return-await': ['error', 'in-try-catch'],
'import/no-internal-modules': 'off',
'import/no-internal-modules': [
'error',
{
allow: [
'react-dom/test-utils',
'memfs/lib/volume.js',
'yargs/**',
'msw/node',
],
},
],
'import/no-relative-packages': 'error',
'no-cond-assign': 'error',
'no-debugger': 'error',
-143
View File
@@ -1,143 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig, checkModelOutputContent } from './test-helper.js';
describe('Plan Mode', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('should allow read-only tools but deny write tools in plan mode', async () => {
await rig.setup(
'should allow read-only tools but deny write tools in plan mode',
{
settings: {
experimental: { plan: true },
tools: {
core: [
'run_shell_command',
'list_directory',
'write_file',
'read_file',
],
},
},
},
);
// We use a prompt that asks for both a read-only action and a write action.
// "List files" (read-only) followed by "touch denied.txt" (write).
const result = await rig.run({
approvalMode: 'plan',
stdin:
'Please list the files in the current directory, and then attempt to create a new file named "denied.txt" using a shell command.',
});
const lsCallFound = await rig.waitForToolCall('list_directory');
expect(lsCallFound, 'Expected list_directory to be called').toBe(true);
const shellCallFound = await rig.waitForToolCall('run_shell_command');
expect(shellCallFound, 'Expected run_shell_command to fail').toBe(false);
const toolLogs = rig.readToolLogs();
const lsLog = toolLogs.find((l) => l.toolRequest.name === 'list_directory');
expect(
toolLogs.find((l) => l.toolRequest.name === 'run_shell_command'),
).toBeUndefined();
expect(lsLog?.toolRequest.success).toBe(true);
checkModelOutputContent(result, {
expectedContent: ['Plan Mode', 'read-only'],
testName: 'Plan Mode restrictions test',
});
});
it('should allow write_file only in the plans directory in plan mode', async () => {
await rig.setup(
'should allow write_file only in the plans directory in plan mode',
{
settings: {
experimental: { plan: true },
tools: {
core: ['write_file', 'read_file', 'list_directory'],
allowed: ['write_file'],
},
general: { defaultApprovalMode: 'plan' },
},
},
);
// We ask the agent to create a plan for a feature, which should trigger a write_file in the plans directory.
// Verify that write_file outside of plan directory fails
await rig.run({
approvalMode: 'plan',
stdin:
'Create a file called plan.md in the plans directory. Then create a file called hello.txt in the current directory',
});
const toolLogs = rig.readToolLogs();
const writeLogs = toolLogs.filter(
(l) => l.toolRequest.name === 'write_file',
);
const planWrite = writeLogs.find(
(l) =>
l.toolRequest.args.includes('plans') &&
l.toolRequest.args.includes('plan.md'),
);
const blockedWrite = writeLogs.find((l) =>
l.toolRequest.args.includes('hello.txt'),
);
// Model is undeterministic, sometimes a blocked write appears in tool logs and sometimes it doesn't
if (blockedWrite) {
expect(blockedWrite?.toolRequest.success).toBe(false);
}
expect(planWrite?.toolRequest.success).toBe(true);
});
it('should be able to enter plan mode from default mode', async () => {
await rig.setup('should be able to enter plan mode from default mode', {
settings: {
experimental: { plan: true },
tools: {
core: ['enter_plan_mode'],
allowed: ['enter_plan_mode'],
},
},
});
// Start in default mode and ask to enter plan mode.
await rig.run({
approvalMode: 'default',
stdin:
'I want to perform a complex refactoring. Please enter plan mode so we can design it first.',
});
const enterPlanCallFound = await rig.waitForToolCall(
'enter_plan_mode',
10000,
);
expect(enterPlanCallFound, 'Expected enter_plan_mode to be called').toBe(
true,
);
const toolLogs = rig.readToolLogs();
const enterLog = toolLogs.find(
(l) => l.toolRequest.name === 'enter_plan_mode',
);
expect(enterLog?.toolRequest.success).toBe(true);
});
});
-136
View File
@@ -1,136 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
import { TestRig, InteractiveRun } from './test-helper.js';
import * as fs from 'node:fs';
import * as os from 'node:os';
import {
writeFileSync,
mkdirSync,
symlinkSync,
readFileSync,
unlinkSync,
} from 'node:fs';
import { join, dirname } from 'node:path';
import { GEMINI_DIR } from '@google/gemini-cli-core';
import * as pty from '@lydell/node-pty';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BUNDLE_PATH = join(__dirname, '..', 'bundle/gemini.js');
const extension = `{
"name": "test-symlink-extension",
"version": "0.0.1"
}`;
const otherExtension = `{
"name": "malicious-extension",
"version": "6.6.6"
}`;
describe('extension symlink install spoofing protection', () => {
let rig: TestRig;
beforeEach(() => {
rig = new TestRig();
});
afterEach(async () => await rig.cleanup());
it('canonicalizes the trust path and prevents symlink spoofing', async () => {
// Enable folder trust for this test
rig.setup('symlink spoofing test', {
settings: {
security: {
folderTrust: {
enabled: true,
},
},
},
});
const realExtPath = join(rig.testDir!, 'real-extension');
mkdirSync(realExtPath);
writeFileSync(join(realExtPath, 'gemini-extension.json'), extension);
const maliciousExtPath = join(
os.tmpdir(),
`malicious-extension-${Date.now()}`,
);
mkdirSync(maliciousExtPath);
writeFileSync(
join(maliciousExtPath, 'gemini-extension.json'),
otherExtension,
);
const symlinkPath = join(rig.testDir!, 'symlink-extension');
symlinkSync(realExtPath, symlinkPath);
// Function to run a command with a PTY to avoid headless mode
const runPty = (args: string[]) => {
const ptyProcess = pty.spawn(process.execPath, [BUNDLE_PATH, ...args], {
name: 'xterm-color',
cols: 80,
rows: 80,
cwd: rig.testDir!,
env: {
...process.env,
GEMINI_CLI_HOME: rig.homeDir!,
GEMINI_CLI_INTEGRATION_TEST: 'true',
GEMINI_PTY_INFO: 'node-pty',
},
});
return new InteractiveRun(ptyProcess);
};
// 1. Install via symlink, trust it
const run1 = runPty(['extensions', 'install', symlinkPath]);
await run1.expectText('Do you want to trust this folder', 30000);
await run1.type('y\r');
await run1.expectText('trust this workspace', 30000);
await run1.type('y\r');
await run1.expectText('Do you want to continue', 30000);
await run1.type('y\r');
await run1.expectText('installed successfully', 30000);
await run1.kill();
// 2. Verify trustedFolders.json contains the REAL path, not the symlink path
const trustedFoldersPath = join(
rig.homeDir!,
GEMINI_DIR,
'trustedFolders.json',
);
// Wait for file to be written
let attempts = 0;
while (!fs.existsSync(trustedFoldersPath) && attempts < 50) {
await new Promise((resolve) => setTimeout(resolve, 100));
attempts++;
}
const trustedFolders = JSON.parse(
readFileSync(trustedFoldersPath, 'utf-8'),
);
const trustedPaths = Object.keys(trustedFolders);
const canonicalRealExtPath = fs.realpathSync(realExtPath);
expect(trustedPaths).toContain(canonicalRealExtPath);
expect(trustedPaths).not.toContain(symlinkPath);
// 3. Swap the symlink to point to the malicious extension
unlinkSync(symlinkPath);
symlinkSync(maliciousExtPath, symlinkPath);
// 4. Try to install again via the same symlink path.
// It should NOT be trusted because the real path changed.
const run2 = runPty(['extensions', 'install', symlinkPath]);
await run2.expectText('Do you want to trust this folder', 30000);
await run2.type('n\r');
await run2.expectText('Installation aborted', 30000);
await run2.kill();
}, 60000);
});
+472 -566
View File
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -71,9 +71,7 @@
},
"glob": "^12.0.0",
"node-domexception": "npm:empty@^0.10.1",
"prebuild-install": "npm:nop@1.0.0",
"cross-spawn": "^7.0.6",
"minimatch": "^10.2.2"
"prebuild-install": "npm:nop@1.0.0"
},
"bin": {
"gemini": "bundle/gemini.js"
@@ -106,7 +104,7 @@
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-headers": "^1.3.3",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"glob": "^12.0.0",
@@ -0,0 +1,41 @@
# Policy engine example extension
This extension demonstrates how to contribute security rules and safety checkers
to the Gemini CLI Policy Engine.
## Description
The extension uses a `policies/` directory containing `.toml` files to define:
- A rule that requires user confirmation for `rm -rf` commands.
- A rule that denies searching for sensitive files (like `.env`) using `grep`.
- A safety checker that validates file paths for all write operations.
## Structure
- `gemini-extension.json`: The manifest file.
- `policies/`: Contains the `.toml` policy files.
## How to use
1. Link this extension to your local Gemini CLI installation:
```bash
gemini extensions link packages/cli/src/commands/extensions/examples/policies
```
2. Restart your Gemini CLI session.
3. **Observe the policies:**
- Try asking the model to delete a directory: The policy engine will prompt
you for confirmation due to the `rm -rf` rule.
- Try asking the model to search for secrets: The `grep` rule will deny the
request and display the custom deny message.
- Any file write operation will now be processed through the `allowed-path`
safety checker.
## Security note
For security, Gemini CLI ignores any `allow` decisions or `yolo` mode
configurations contributed by extensions. This ensures that extensions can
strengthen security but cannot bypass user confirmation.
@@ -0,0 +1,5 @@
{
"name": "policy-example",
"version": "1.0.0",
"description": "An example extension demonstrating Policy Engine support."
}
@@ -0,0 +1,28 @@
# Example Policy Rules for Gemini CLI Extension
#
# Extensions run in Tier 2 (Extension Tier).
# Security Note: 'allow' decisions and 'yolo' mode configurations are ignored.
# Rule: Always ask the user before running a specific dangerous shell command.
[[rule]]
toolName = "run_shell_command"
commandPrefix = "rm -rf"
decision = "ask_user"
priority = 100
# Rule: Deny access to sensitive files using the grep tool.
[[rule]]
toolName = "grep_search"
argsPattern = "(\.env|id_rsa|passwd)"
decision = "deny"
priority = 200
deny_message = "Access to sensitive credentials or system files is restricted by the policy-example extension."
# Safety Checker: Apply path validation to all write operations.
[[safety_checker]]
toolName = ["write_file", "replace"]
priority = 300
[safety_checker.checker]
type = "in-process"
name = "allowed-path"
required_context = ["environment"]
@@ -16,20 +16,14 @@ import {
} from 'vitest';
import { handleInstall, installCommand } from './install.js';
import yargs from 'yargs';
import * as core from '@google/gemini-cli-core';
import type { inferInstallMetadata } from '../../config/extension-manager.js';
import { ExtensionManager } from '../../config/extension-manager.js';
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
import type {
promptForConsentNonInteractive,
requestConsentNonInteractive,
} from '../../config/extensions/consent.js';
import type {
isWorkspaceTrusted,
loadTrustedFolders,
} from '../../config/trustedFolders.js';
ExtensionManager,
inferInstallMetadata,
} from '../../config/extension-manager.js';
import type { requestConsentNonInteractive } from '../../config/extensions/consent.js';
import type * as fs from 'node:fs/promises';
import type { Stats } from 'node:fs';
import * as path from 'node:path';
const mockInstallOrUpdateExtension: Mock<
typeof ExtensionManager.prototype.installOrUpdateExtension
@@ -37,54 +31,28 @@ const mockInstallOrUpdateExtension: Mock<
const mockRequestConsentNonInteractive: Mock<
typeof requestConsentNonInteractive
> = vi.hoisted(() => vi.fn());
const mockPromptForConsentNonInteractive: Mock<
typeof promptForConsentNonInteractive
> = vi.hoisted(() => vi.fn());
const mockStat: Mock<typeof fs.stat> = vi.hoisted(() => vi.fn());
const mockInferInstallMetadata: Mock<typeof inferInstallMetadata> = vi.hoisted(
() => vi.fn(),
);
const mockIsWorkspaceTrusted: Mock<typeof isWorkspaceTrusted> = vi.hoisted(() =>
vi.fn(),
);
const mockLoadTrustedFolders: Mock<typeof loadTrustedFolders> = vi.hoisted(() =>
vi.fn(),
);
const mockDiscover: Mock<typeof core.FolderTrustDiscoveryService.discover> =
vi.hoisted(() => vi.fn());
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
promptForConsentNonInteractive: mockPromptForConsentNonInteractive,
INSTALL_WARNING_MESSAGE: 'warning',
}));
vi.mock('../../config/trustedFolders.js', () => ({
isWorkspaceTrusted: mockIsWorkspaceTrusted,
loadTrustedFolders: mockLoadTrustedFolders,
TrustLevel: {
TRUST_FOLDER: 'TRUST_FOLDER',
},
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
await importOriginal<typeof import('../../config/extension-manager.js')>();
return {
...actual,
FolderTrustDiscoveryService: {
discover: mockDiscover,
},
ExtensionManager: vi.fn().mockImplementation(() => ({
installOrUpdateExtension: mockInstallOrUpdateExtension,
loadExtensions: vi.fn(),
})),
inferInstallMetadata: mockInferInstallMetadata,
};
});
vi.mock('../../config/extension-manager.js', async (importOriginal) => ({
...(await importOriginal<
typeof import('../../config/extension-manager.js')
>()),
inferInstallMetadata: mockInferInstallMetadata,
}));
vi.mock('../../utils/errors.js', () => ({
getErrorMessage: vi.fn((error: Error) => error.message),
}));
@@ -115,31 +83,12 @@ describe('handleInstall', () => {
let processSpy: MockInstance;
beforeEach(() => {
debugLogSpy = vi.spyOn(core.debugLogger, 'log');
debugErrorSpy = vi.spyOn(core.debugLogger, 'error');
debugLogSpy = vi.spyOn(debugLogger, 'log');
debugErrorSpy = vi.spyOn(debugLogger, 'error');
processSpy = vi
.spyOn(process, 'exit')
.mockImplementation(() => undefined as never);
vi.spyOn(ExtensionManager.prototype, 'loadExtensions').mockResolvedValue(
[],
);
vi.spyOn(
ExtensionManager.prototype,
'installOrUpdateExtension',
).mockImplementation(mockInstallOrUpdateExtension);
mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
mockDiscover.mockResolvedValue({
commands: [],
mcps: [],
hooks: [],
skills: [],
settings: [],
securityWarnings: [],
discoveryErrors: [],
});
mockInferInstallMetadata.mockImplementation(async (source, args) => {
if (
source.startsWith('http://') ||
@@ -165,29 +114,12 @@ describe('handleInstall', () => {
mockStat.mockClear();
mockInferInstallMetadata.mockClear();
vi.clearAllMocks();
vi.restoreAllMocks();
});
function createMockExtension(
overrides: Partial<core.GeminiCLIExtension> = {},
): core.GeminiCLIExtension {
return {
name: 'mock-extension',
version: '1.0.0',
isActive: true,
path: '/mock/path',
contextFiles: [],
id: 'mock-id',
...overrides,
};
}
it('should install an extension from a http source', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'http-extension',
}),
);
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'http-extension',
} as unknown as GeminiCLIExtension);
await handleInstall({
source: 'http://google.com',
@@ -199,11 +131,9 @@ describe('handleInstall', () => {
});
it('should install an extension from a https source', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'https-extension',
}),
);
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'https-extension',
} as unknown as GeminiCLIExtension);
await handleInstall({
source: 'https://google.com',
@@ -215,11 +145,9 @@ describe('handleInstall', () => {
});
it('should install an extension from a git source', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'git-extension',
}),
);
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'git-extension',
} as unknown as GeminiCLIExtension);
await handleInstall({
source: 'git@some-url',
@@ -243,11 +171,9 @@ describe('handleInstall', () => {
});
it('should install an extension from a sso source', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'sso-extension',
}),
);
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'sso-extension',
} as unknown as GeminiCLIExtension);
await handleInstall({
source: 'sso://google.com',
@@ -259,14 +185,12 @@ describe('handleInstall', () => {
});
it('should install an extension from a local path', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'local-extension',
}),
);
mockInstallOrUpdateExtension.mockResolvedValue({
name: 'local-extension',
} as unknown as GeminiCLIExtension);
mockStat.mockResolvedValue({} as Stats);
await handleInstall({
source: path.join('/', 'some', 'path'),
source: '/some/path',
});
expect(debugLogSpy).toHaveBeenCalledWith(
@@ -284,144 +208,4 @@ describe('handleInstall', () => {
expect(debugErrorSpy).toHaveBeenCalledWith('Install extension failed');
expect(processSpy).toHaveBeenCalledWith(1);
});
it('should proceed if local path is already trusted', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'local-extension',
}),
);
mockStat.mockResolvedValue({} as Stats);
mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
await handleInstall({
source: path.join('/', 'some', 'path'),
});
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
expect(mockPromptForConsentNonInteractive).not.toHaveBeenCalled();
expect(debugLogSpy).toHaveBeenCalledWith(
'Extension "local-extension" installed successfully and enabled.',
);
});
it('should prompt and proceed if user accepts trust', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'local-extension',
}),
);
mockStat.mockResolvedValue({} as Stats);
mockIsWorkspaceTrusted.mockReturnValue({
isTrusted: undefined,
source: undefined,
});
mockPromptForConsentNonInteractive.mockResolvedValue(true);
const mockSetValue = vi.fn();
mockLoadTrustedFolders.mockReturnValue({
setValue: mockSetValue,
user: { path: '', config: {} },
errors: [],
rules: [],
isPathTrusted: vi.fn(),
});
await handleInstall({
source: path.join('/', 'untrusted', 'path'),
});
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
expect(mockPromptForConsentNonInteractive).toHaveBeenCalled();
expect(mockSetValue).toHaveBeenCalledWith(
expect.stringContaining(path.join('untrusted', 'path')),
'TRUST_FOLDER',
);
expect(debugLogSpy).toHaveBeenCalledWith(
'Extension "local-extension" installed successfully and enabled.',
);
});
it('should prompt and abort if user denies trust', async () => {
mockStat.mockResolvedValue({} as Stats);
mockIsWorkspaceTrusted.mockReturnValue({
isTrusted: undefined,
source: undefined,
});
mockPromptForConsentNonInteractive.mockResolvedValue(false);
await handleInstall({
source: path.join('/', 'evil', 'path'),
});
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
expect(mockPromptForConsentNonInteractive).toHaveBeenCalled();
expect(debugErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('Installation aborted: Folder'),
);
expect(processSpy).toHaveBeenCalledWith(1);
});
it('should include discovery results in trust prompt', async () => {
mockInstallOrUpdateExtension.mockResolvedValue(
createMockExtension({
name: 'local-extension',
}),
);
mockStat.mockResolvedValue({} as Stats);
mockIsWorkspaceTrusted.mockReturnValue({
isTrusted: undefined,
source: undefined,
});
mockDiscover.mockResolvedValue({
commands: ['custom-cmd'],
mcps: [],
hooks: [],
skills: ['cool-skill'],
settings: [],
securityWarnings: ['Security risk!'],
discoveryErrors: ['Read error'],
});
mockPromptForConsentNonInteractive.mockResolvedValue(true);
mockLoadTrustedFolders.mockReturnValue({
setValue: vi.fn(),
user: { path: '', config: {} },
errors: [],
rules: [],
isPathTrusted: vi.fn(),
});
await handleInstall({
source: '/untrusted/path',
});
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('This folder contains:'),
false,
);
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('custom-cmd'),
false,
);
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('cool-skill'),
false,
);
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('Security Warnings:'),
false,
);
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('Security risk!'),
false,
);
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('Discovery Errors:'),
false,
);
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
expect.stringContaining('Read error'),
false,
);
});
});
// Implementation completed.
+3 -102
View File
@@ -5,16 +5,10 @@
*/
import type { CommandModule } from 'yargs';
import chalk from 'chalk';
import {
debugLogger,
FolderTrustDiscoveryService,
getRealPath,
} from '@google/gemini-cli-core';
import { debugLogger } from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import {
INSTALL_WARNING_MESSAGE,
promptForConsentNonInteractive,
requestConsentNonInteractive,
} from '../../config/extensions/consent.js';
import {
@@ -22,11 +16,6 @@ import {
inferInstallMetadata,
} from '../../config/extension-manager.js';
import { loadSettings } from '../../config/settings.js';
import {
isWorkspaceTrusted,
loadTrustedFolders,
TrustLevel,
} from '../../config/trustedFolders.js';
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
import { exitCli } from '../utils.js';
@@ -47,95 +36,6 @@ export async function handleInstall(args: InstallArgs) {
allowPreRelease: args.allowPreRelease,
});
const workspaceDir = process.cwd();
const settings = loadSettings(workspaceDir).merged;
if (installMetadata.type === 'local' || installMetadata.type === 'link') {
const resolvedPath = getRealPath(source);
installMetadata.source = resolvedPath;
const trustResult = isWorkspaceTrusted(settings, resolvedPath);
if (trustResult.isTrusted !== true) {
const discoveryResults =
await FolderTrustDiscoveryService.discover(resolvedPath);
const hasDiscovery =
discoveryResults.commands.length > 0 ||
discoveryResults.mcps.length > 0 ||
discoveryResults.hooks.length > 0 ||
discoveryResults.skills.length > 0 ||
discoveryResults.settings.length > 0;
const promptLines = [
'',
chalk.bold('Do you trust the files in this folder?'),
'',
`The extension source at "${resolvedPath}" is not trusted.`,
'',
'Trusting a folder allows Gemini CLI to load its local configurations,',
'including custom commands, hooks, MCP servers, agent skills, and',
'settings. These configurations could execute code on your behalf or',
'change the behavior of the CLI.',
'',
];
if (discoveryResults.discoveryErrors.length > 0) {
promptLines.push(chalk.red('❌ Discovery Errors:'));
for (const error of discoveryResults.discoveryErrors) {
promptLines.push(chalk.red(`${error}`));
}
promptLines.push('');
}
if (discoveryResults.securityWarnings.length > 0) {
promptLines.push(chalk.yellow('⚠️ Security Warnings:'));
for (const warning of discoveryResults.securityWarnings) {
promptLines.push(chalk.yellow(`${warning}`));
}
promptLines.push('');
}
if (hasDiscovery) {
promptLines.push(chalk.bold('This folder contains:'));
const groups = [
{ label: 'Commands', items: discoveryResults.commands },
{ label: 'MCP Servers', items: discoveryResults.mcps },
{ label: 'Hooks', items: discoveryResults.hooks },
{ label: 'Skills', items: discoveryResults.skills },
{ label: 'Setting overrides', items: discoveryResults.settings },
].filter((g) => g.items.length > 0);
for (const group of groups) {
promptLines.push(
`${chalk.bold(group.label)} (${group.items.length}):`,
);
for (const item of group.items) {
promptLines.push(` - ${item}`);
}
}
promptLines.push('');
}
promptLines.push(
chalk.yellow(
'Do you want to trust this folder and continue with the installation? [y/N]: ',
),
);
const confirmed = await promptForConsentNonInteractive(
promptLines.join('\n'),
false,
);
if (confirmed) {
const trustedFolders = loadTrustedFolders();
await trustedFolders.setValue(resolvedPath, TrustLevel.TRUST_FOLDER);
} else {
throw new Error(
`Installation aborted: Folder "${resolvedPath}" is not trusted.`,
);
}
}
}
const requestConsent = args.consent
? () => Promise.resolve(true)
: requestConsentNonInteractive;
@@ -144,11 +44,12 @@ export async function handleInstall(args: InstallArgs) {
debugLogger.log(INSTALL_WARNING_MESSAGE);
}
const workspaceDir = process.cwd();
const extensionManager = new ExtensionManager({
workspaceDir,
requestConsent,
requestSetting: promptForSetting,
settings,
settings: loadSettings(workspaceDir).merged,
});
await extensionManager.loadExtensions();
const extension =
+116
View File
@@ -19,6 +19,8 @@ import {
debugLogger,
ApprovalMode,
type MCPServerConfig,
Storage,
type GeminiCLIExtension,
} from '@google/gemini-cli-core';
import { loadCliConfig, parseArguments, type CliArgs } from './config.js';
import {
@@ -3465,3 +3467,117 @@ describe('loadCliConfig mcpEnabled', () => {
expect(config.getBlockedMcpServers()).toEqual(['serverB']);
});
});
describe('loadCliConfig extension plan settings', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
// Mock getProjectIdentifier to avoid "Storage must be initialized before use" error
// when accessing plansDir without a custom directory set.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.spyOn(Storage.prototype as any, 'getProjectIdentifier').mockReturnValue(
'test-project',
);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('should use plan directory from active extension when user has not specified one', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
});
const argv = await parseArguments(settings);
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
{
name: 'ext-plan',
isActive: true,
plan: { directory: 'ext-plans-dir' },
} as unknown as GeminiCLIExtension,
]);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.storage.getPlansDir()).toContain('ext-plans-dir');
});
it('should prefer user-specified plan directory over extension-provided one', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
general: {
plan: { directory: 'user-plans-dir' },
},
});
const argv = await parseArguments(settings);
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
{
name: 'ext-plan',
isActive: true,
plan: { directory: 'ext-plans-dir' },
} as unknown as GeminiCLIExtension,
]);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.storage.getPlansDir()).toContain('user-plans-dir');
expect(config.storage.getPlansDir()).not.toContain('ext-plans-dir');
});
it('should use the first active extension plan directory and log a warning if multiple are found', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
});
const argv = await parseArguments(settings);
const warnSpy = vi.spyOn(debugLogger, 'warn');
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
{
name: 'ext-plan-1',
isActive: true,
plan: { directory: 'ext-plans-dir-1' },
} as unknown as GeminiCLIExtension,
{
name: 'ext-plan-2',
isActive: true,
plan: { directory: 'ext-plans-dir-2' },
} as unknown as GeminiCLIExtension,
]);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.storage.getPlansDir()).toContain('ext-plans-dir-1');
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
'Multiple active extensions define a plan directory',
),
);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('ext-plan-1'));
});
it('should ignore plan directory from inactive extensions', async () => {
process.argv = ['node', 'script.js'];
const settings = createTestMergedSettings({
experimental: { plan: true },
});
const argv = await parseArguments(settings);
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([
{
name: 'ext-plan-inactive',
isActive: false,
plan: { directory: 'ext-plans-dir-inactive' },
} as unknown as GeminiCLIExtension,
]);
const config = await loadCliConfig(settings, 'test-session', argv);
expect(config.storage.getPlansDir()).not.toContain(
'ext-plans-dir-inactive',
);
});
});
+19 -1
View File
@@ -33,6 +33,7 @@ import {
getVersion,
PREVIEW_GEMINI_MODEL_AUTO,
type HierarchicalMemory,
type PlanSettings,
coreEvents,
GEMINI_MODEL_ALIAS_AUTO,
getAdminErrorMessage,
@@ -511,6 +512,21 @@ export async function loadCliConfig(
});
await extensionManager.loadExtensions();
// Filter active extensions that define a plan directory
const activeExtensionsWithPlan = extensionManager
.getExtensions()
.filter((e) => e.isActive && e.plan?.directory);
let extensionPlanSettings: PlanSettings | undefined;
if (activeExtensionsWithPlan.length > 0) {
if (activeExtensionsWithPlan.length > 1) {
debugLogger.warn(
`Multiple active extensions define a plan directory. Using plan directory from extension: "${activeExtensionsWithPlan[0].name}"`,
);
}
extensionPlanSettings = activeExtensionsWithPlan[0].plan;
}
const experimentalJitContext = settings.experimental?.jitContext ?? false;
let memoryContent: string | HierarchicalMemory = '';
@@ -827,7 +843,9 @@ export async function loadCliConfig(
enableAgents: settings.experimental?.enableAgents,
plan: settings.experimental?.plan,
directWebFetch: settings.experimental?.directWebFetch,
planSettings: settings.general?.plan,
planSettings: settings.general?.plan?.directory
? settings.general.plan
: (extensionPlanSettings ?? settings.general?.plan),
enableEventDrivenScheduler: true,
skillsSupport: settings.skills?.enabled ?? true,
disabledSkills: settings.skills?.disabled,
@@ -9,11 +9,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { ExtensionManager } from './extension-manager.js';
import {
debugLogger,
coreEvents,
type CommandHookConfig,
} from '@google/gemini-cli-core';
import { debugLogger, coreEvents } from '@google/gemini-cli-core';
import { createTestMergedSettings } from './settings.js';
import { createExtension } from '../test-utils/createExtension.js';
import { EXTENSIONS_DIRECTORY_NAME } from './extensions/variables.js';
@@ -252,11 +248,9 @@ System using model: \${MODEL_NAME}
expect(extension.hooks).toBeDefined();
expect(extension.hooks?.BeforeTool).toHaveLength(1);
expect(
(extension.hooks?.BeforeTool![0].hooks[0] as CommandHookConfig).env?.[
'HOOK_CMD'
],
).toBe('hello-world');
expect(extension.hooks?.BeforeTool![0].hooks[0].env?.['HOOK_CMD']).toBe(
'hello-world',
);
});
it('should pick up new settings after restartExtension', async () => {
+103 -14
View File
@@ -32,7 +32,6 @@ import {
ExtensionUninstallEvent,
ExtensionUpdateEvent,
getErrorMessage,
getRealPath,
logExtensionDisable,
logExtensionEnable,
logExtensionInstallEvent,
@@ -52,7 +51,13 @@ import {
applyAdminAllowlist,
getAdminBlockedMcpServersMessage,
CoreToolCallStatus,
HookType,
EXTENSION_POLICY_TIER,
loadPoliciesFromToml,
PolicyDecision,
ApprovalMode,
isSubpath,
type PolicyRule,
type SafetyCheckerRule,
} from '@google/gemini-cli-core';
import { maybeRequestConsentOrFail } from './extensions/consent.js';
import { resolveEnvVarsInObject } from '../utils/envVarResolver.js';
@@ -204,11 +209,13 @@ export class ExtensionManager extends ExtensionLoader {
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
await fs.promises.mkdir(extensionsDir, { recursive: true });
if (installMetadata.type === 'local' || installMetadata.type === 'link') {
installMetadata.source = getRealPath(
path.isAbsolute(installMetadata.source)
? installMetadata.source
: path.resolve(this.workspaceDir, installMetadata.source),
if (
!path.isAbsolute(installMetadata.source) &&
(installMetadata.type === 'local' || installMetadata.type === 'link')
) {
installMetadata.source = path.resolve(
this.workspaceDir,
installMetadata.source,
);
}
@@ -698,9 +705,18 @@ Would you like to attempt to install via "git clone" instead?`,
}
const contextFiles = getContextFileNames(config)
.map((contextFileName) =>
path.join(effectiveExtensionPath, contextFileName),
)
.map((contextFileName) => {
const contextFilePath = path.join(
effectiveExtensionPath,
contextFileName,
);
if (!isSubpath(effectiveExtensionPath, contextFilePath)) {
throw new Error(
`Invalid context file path: "${contextFileName}". Context files must be within the extension directory.`,
);
}
return contextFilePath;
})
.filter((contextFilePath) => fs.existsSync(contextFilePath));
const hydrationContext: VariableContext = {
@@ -736,10 +752,8 @@ Would you like to attempt to install via "git clone" instead?`,
if (eventHooks) {
for (const definition of eventHooks) {
for (const hook of definition.hooks) {
if (hook.type === HookType.Command) {
// Merge existing env with new env vars, giving extension settings precedence.
hook.env = { ...hook.env, ...hookEnv };
}
// Merge existing env with new env vars, giving extension settings precedence.
hook.env = { ...hook.env, ...hookEnv };
}
}
}
@@ -754,6 +768,78 @@ Would you like to attempt to install via "git clone" instead?`,
recursivelyHydrateStrings(skill, hydrationContext),
);
let rules: PolicyRule[] | undefined;
let checkers: SafetyCheckerRule[] | undefined;
const policyDir = path.join(effectiveExtensionPath, 'policies');
if (!isSubpath(effectiveExtensionPath, policyDir)) {
throw new Error(
`Invalid policy directory path. Policies must be within the extension directory.`,
);
}
if (fs.existsSync(policyDir)) {
const result = await loadPoliciesFromToml(
[policyDir],
() => EXTENSION_POLICY_TIER,
);
rules = result.rules;
checkers = result.checkers;
// Prefix source with extension name to avoid collisions
if (rules) {
rules = rules.filter((rule) => {
// Security: Extensions are not allowed to automatically approve tool calls.
// We ignore any rule that is ALLOW.
if (rule.decision === PolicyDecision.ALLOW) {
debugLogger.warn(
`[ExtensionManager] Extension "${config.name}" attempted to contribute an ALLOW rule for tool "${rule.toolName}". Ignoring this rule for security.`,
);
return false;
}
// Security: Extensions are not allowed to contribute YOLO mode rules.
if (rule.modes?.includes(ApprovalMode.YOLO)) {
debugLogger.warn(
`[ExtensionManager] Extension "${config.name}" attempted to contribute a rule for YOLO mode. Ignoring this rule for security.`,
);
return false;
}
rule.source = rule.source?.startsWith(`Extension (${config.name}):`)
? rule.source
: `Extension (${config.name}): ${rule.source}`;
return true;
});
}
if (checkers) {
checkers = checkers.filter((checker) => {
// Security: Extensions are not allowed to contribute YOLO mode checkers.
if (checker.modes?.includes(ApprovalMode.YOLO)) {
debugLogger.warn(
`[ExtensionManager] Extension "${config.name}" attempted to contribute a safety checker for YOLO mode. Ignoring this checker for security.`,
);
return false;
}
checker.source = checker.source?.startsWith(
`Extension (${config.name}):`,
)
? checker.source
: `Extension (${config.name}): ${checker.source}`;
return true;
});
}
if (result.errors.length > 0) {
for (const error of result.errors) {
debugLogger.warn(
`[ExtensionManager] Error loading policies from ${config.name}: ${error.message}${error.details ? `\nDetails: ${error.details}` : ''}`,
);
}
}
}
const agentLoadResult = await loadAgentsFromDirectory(
path.join(effectiveExtensionPath, 'agents'),
);
@@ -776,6 +862,7 @@ Would you like to attempt to install via "git clone" instead?`,
installMetadata,
mcpServers: config.mcpServers,
excludeTools: config.excludeTools,
plan: config.plan,
hooks,
isActive: this.extensionEnablementManager.isEnabled(
config.name,
@@ -787,6 +874,8 @@ Would you like to attempt to install via "git clone" instead?`,
skills,
agents: agentLoadResult.agents,
themes: config.themes,
rules,
checkers,
};
this.loadedExtensions = [...this.loadedExtensions, extension];
+187 -69
View File
@@ -25,7 +25,6 @@ import {
KeychainTokenStorage,
loadAgentsFromDirectory,
loadSkillsFromDir,
getRealPath,
} from '@google/gemini-cli-core';
import {
loadSettings,
@@ -187,11 +186,11 @@ describe('extension tests', () => {
errors: [],
});
vi.mocked(loadSkillsFromDir).mockResolvedValue([]);
tempHomeDir = getRealPath(
fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-home-')),
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
tempWorkspaceDir = getRealPath(
fs.mkdtempSync(path.join(tempHomeDir, 'gemini-cli-test-workspace-')),
tempWorkspaceDir = fs.mkdtempSync(
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
);
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
mockRequestConsent = vi.fn();
@@ -239,6 +238,27 @@ describe('extension tests', () => {
expect(extensions[0].name).toBe('test-extension');
});
it('should throw an error if a context file path is outside the extension directory', async () => {
const consoleSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {});
createExtension({
extensionsDir: userExtensionsDir,
name: 'traversal-extension',
version: '1.0.0',
contextFileName: '../secret.txt',
});
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(0);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'traversal-extension: Invalid context file path: "../secret.txt"',
),
);
consoleSpy.mockRestore();
});
it('should load context file path when GEMINI.md is present', async () => {
createExtension({
extensionsDir: userExtensionsDir,
@@ -330,14 +350,12 @@ describe('extension tests', () => {
});
it('should load a linked extension correctly', async () => {
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'my-linked-extension',
version: '1.0.0',
contextFileName: 'context.md',
}),
);
const sourceExtDir = createExtension({
extensionsDir: tempWorkspaceDir,
name: 'my-linked-extension',
version: '1.0.0',
contextFileName: 'context.md',
});
fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context');
await extensionManager.loadExtensions();
@@ -363,22 +381,125 @@ describe('extension tests', () => {
]);
});
it('should hydrate ${extensionPath} correctly for linked extensions', async () => {
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'my-linked-extension-with-path',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node',
args: ['${extensionPath}${/}server${/}index.js'],
cwd: '${extensionPath}${/}server',
},
},
}),
it('should load extension policies from the policies directory', async () => {
const extDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'policy-extension',
version: '1.0.0',
});
const policiesDir = path.join(extDir, 'policies');
fs.mkdirSync(policiesDir);
const policiesContent = `
[[rule]]
toolName = "deny_tool"
decision = "deny"
priority = 500
[[rule]]
toolName = "ask_tool"
decision = "ask_user"
priority = 100
`;
fs.writeFileSync(
path.join(policiesDir, 'policies.toml'),
policiesContent,
);
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
const extension = extensions[0];
expect(extension.rules).toBeDefined();
expect(extension.rules).toHaveLength(2);
expect(
extension.rules!.find((r) => r.toolName === 'deny_tool')?.decision,
).toBe('deny');
expect(
extension.rules!.find((r) => r.toolName === 'ask_tool')?.decision,
).toBe('ask_user');
// Verify source is prefixed
expect(extension.rules![0].source).toContain(
'Extension (policy-extension):',
);
});
it('should ignore ALLOW rules and YOLO mode from extension policies for security', async () => {
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const extDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'security-test-extension',
version: '1.0.0',
});
const policiesDir = path.join(extDir, 'policies');
fs.mkdirSync(policiesDir);
const policiesContent = `
[[rule]]
toolName = "allow_tool"
decision = "allow"
priority = 100
[[rule]]
toolName = "yolo_tool"
decision = "ask_user"
priority = 100
modes = ["yolo"]
[[safety_checker]]
toolName = "yolo_check"
priority = 100
modes = ["yolo"]
[safety_checker.checker]
type = "external"
name = "yolo-checker"
`;
fs.writeFileSync(
path.join(policiesDir, 'policies.toml'),
policiesContent,
);
const extensions = await extensionManager.loadExtensions();
expect(extensions).toHaveLength(1);
const extension = extensions[0];
// ALLOW rules and YOLO rules/checkers should be filtered out
expect(extension.rules).toBeDefined();
expect(extension.rules).toHaveLength(0);
expect(extension.checkers).toBeDefined();
expect(extension.checkers).toHaveLength(0);
// Should have logged warnings
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('attempted to contribute an ALLOW rule'),
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining('attempted to contribute a rule for YOLO mode'),
);
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
'attempted to contribute a safety checker for YOLO mode',
),
);
consoleSpy.mockRestore();
});
it('should hydrate ${extensionPath} correctly for linked extensions', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempWorkspaceDir,
name: 'my-linked-extension-with-path',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node',
args: ['${extensionPath}${/}server${/}index.js'],
cwd: '${extensionPath}${/}server',
},
},
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
source: sourceExtDir,
@@ -540,7 +661,7 @@ describe('extension tests', () => {
// Bad extension
const badExtDir = path.join(userExtensionsDir, 'bad-ext');
fs.mkdirSync(badExtDir);
fs.mkdirSync(badExtDir, { recursive: true });
const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME);
fs.writeFileSync(badConfigPath, '{ "name": "bad-ext"'); // Malformed
@@ -548,7 +669,7 @@ describe('extension tests', () => {
expect(extensions).toHaveLength(1);
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledExactlyOnceWith(
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}`,
),
@@ -571,7 +692,7 @@ describe('extension tests', () => {
// Bad extension
const badExtDir = path.join(userExtensionsDir, 'bad-ext-no-name');
fs.mkdirSync(badExtDir);
fs.mkdirSync(badExtDir, { recursive: true });
const badConfigPath = path.join(badExtDir, EXTENSIONS_CONFIG_FILENAME);
fs.writeFileSync(badConfigPath, JSON.stringify({ version: '1.0.0' }));
@@ -579,7 +700,7 @@ describe('extension tests', () => {
expect(extensions).toHaveLength(1);
expect(extensions[0].name).toBe('good-ext');
expect(consoleSpy).toHaveBeenCalledExactlyOnceWith(
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Warning: Skipping extension in ${badExtDir}: Failed to load extension config from ${badConfigPath}: Invalid configuration in ${badConfigPath}: missing "name"`,
),
@@ -849,13 +970,11 @@ describe('extension tests', () => {
it('should generate id from the original source for linked extensions', async () => {
const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions');
const actualExtensionDir = getRealPath(
createExtension({
extensionsDir: extDevelopmentDir,
name: 'link-ext-name',
version: '1.0.0',
}),
);
const actualExtensionDir = createExtension({
extensionsDir: extDevelopmentDir,
name: 'link-ext-name',
version: '1.0.0',
});
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
type: 'link',
@@ -1001,13 +1120,11 @@ describe('extension tests', () => {
describe('installExtension', () => {
it('should install an extension from a local path', async () => {
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
}),
);
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
});
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
@@ -1049,7 +1166,7 @@ describe('extension tests', () => {
});
it('should throw an error and cleanup if gemini-extension.json is missing', async () => {
const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-extension'));
const sourceExtDir = path.join(tempHomeDir, 'bad-extension');
fs.mkdirSync(sourceExtDir, { recursive: true });
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
@@ -1065,7 +1182,7 @@ describe('extension tests', () => {
});
it('should throw an error for invalid JSON in gemini-extension.json', async () => {
const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-json-ext'));
const sourceExtDir = path.join(tempHomeDir, 'bad-json-ext');
fs.mkdirSync(sourceExtDir, { recursive: true });
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
fs.writeFileSync(configPath, '{ "name": "bad-json", "version": "1.0.0"'); // Malformed JSON
@@ -1075,17 +1192,22 @@ describe('extension tests', () => {
source: sourceExtDir,
type: 'local',
}),
).rejects.toThrow(`Failed to load extension config from ${configPath}`);
).rejects.toThrow(
new RegExp(
`^Failed to load extension config from ${configPath.replace(
/\\/g,
'\\\\',
)}`,
),
);
});
it('should throw an error for missing name in gemini-extension.json', async () => {
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempHomeDir,
name: 'missing-name-ext',
version: '1.0.0',
}),
);
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'missing-name-ext',
version: '1.0.0',
});
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
// Overwrite with invalid config
fs.writeFileSync(configPath, JSON.stringify({ version: '1.0.0' }));
@@ -1138,13 +1260,11 @@ describe('extension tests', () => {
});
it('should install a linked extension', async () => {
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempHomeDir,
name: 'my-linked-extension',
version: '1.0.0',
}),
);
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-linked-extension',
version: '1.0.0',
});
const targetExtDir = path.join(userExtensionsDir, 'my-linked-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME);
@@ -1445,13 +1565,11 @@ ${INSTALL_WARNING_MESSAGE}`,
});
it('should save the autoUpdate flag to the install metadata', async () => {
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
}),
);
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
});
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
+1
View File
@@ -28,6 +28,7 @@ export interface ExtensionConfig {
contextFileName?: string | string[];
excludeTools?: string[];
settings?: ExtensionSetting[];
plan?: { directory?: string };
/**
* Custom themes contributed by this extension.
* These themes will be registered when the extension is activated.
@@ -84,7 +84,7 @@ describe('consent', () => {
{ input: '', expected: true },
{ input: 'n', expected: false },
{ input: 'N', expected: false },
{ input: 'yes', expected: true },
{ input: 'yes', expected: false },
])(
'should return $expected for input "$input"',
async ({ input, expected }) => {
+3 -10
View File
@@ -91,12 +91,10 @@ export async function requestConsentInteractive(
* This should not be called from interactive mode as it will break the CLI.
*
* @param prompt A yes/no prompt to ask the user
* @param defaultValue Whether to resolve as true or false on enter.
* @returns Whether or not the user answers 'y' (yes).
* @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter.
*/
export async function promptForConsentNonInteractive(
async function promptForConsentNonInteractive(
prompt: string,
defaultValue = true,
): Promise<boolean> {
const readline = await import('node:readline');
const rl = readline.createInterface({
@@ -107,12 +105,7 @@ export async function promptForConsentNonInteractive(
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
rl.close();
const trimmedAnswer = answer.trim().toLowerCase();
if (trimmedAnswer === '') {
resolve(defaultValue);
} else {
resolve(['y', 'yes'].includes(trimmedAnswer));
}
resolve(['y', ''].includes(answer.trim().toLowerCase()));
});
});
}
@@ -324,7 +324,7 @@ describe('settings-validation', () => {
expect(formatted).toContain('Expected: string, but received: object');
expect(formatted).toContain('Please fix the configuration.');
expect(formatted).toContain(
'https://geminicli.com/docs/reference/configuration/',
'https://github.com/google-gemini/gemini-cli',
);
}
});
@@ -364,8 +364,9 @@ describe('settings-validation', () => {
const formatted = formatValidationError(result.error, 'test.json');
expect(formatted).toContain(
'https://geminicli.com/docs/reference/configuration/',
'https://github.com/google-gemini/gemini-cli',
);
expect(formatted).toContain('configuration.md');
}
});
@@ -327,7 +327,9 @@ export function formatValidationError(
}
lines.push('Please fix the configuration.');
lines.push('See: https://geminicli.com/docs/reference/configuration/');
lines.push(
'See: https://github.com/google-gemini/gemini-cli/blob/main/docs/get-started/configuration.md',
);
return lines.join('\n');
}
+12 -27
View File
@@ -75,7 +75,6 @@ import {
SettingScope,
LoadedSettings,
sanitizeEnvVar,
createTestMergedSettings,
} from './settings.js';
import {
FatalConfigError,
@@ -1839,50 +1838,36 @@ describe('Settings Loading and Merging', () => {
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
});
it('does not load env files from untrusted spaces when sandboxed', () => {
it('does not load env files from untrusted spaces', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = {
security: { folderTrust: { enabled: true } },
tools: { sandbox: true },
} as Settings;
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
expect(process.env['TESTTEST']).not.toEqual('1234');
});
it('does load env files from untrusted spaces when NOT sandboxed', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = {
security: { folderTrust: { enabled: true } },
tools: { sandbox: false },
} as Settings;
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
expect(process.env['TESTTEST']).toEqual('1234');
});
it('does not load env files when trust is undefined and sandboxed', () => {
it('does not load env files when trust is undefined', () => {
delete process.env['TESTTEST'];
// isWorkspaceTrusted returns {isTrusted: undefined} for matched rules with no trust value, or no matching rules.
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: undefined });
const settings = {
security: { folderTrust: { enabled: true } },
tools: { sandbox: true },
} as Settings;
const mockTrustFn = vi.fn().mockReturnValue({ isTrusted: undefined });
loadEnvironment(settings, MOCK_WORKSPACE_DIR, mockTrustFn);
expect(process.env['TESTTEST']).not.toEqual('1234');
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
expect(process.env['GEMINI_API_KEY']).not.toEqual('test-key');
});
it('loads whitelisted env files from untrusted spaces if sandboxing is enabled', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = createTestMergedSettings({
tools: { sandbox: true },
});
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
settings.merged.tools.sandbox = true;
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
// GEMINI_API_KEY is in the whitelist, so it should be loaded.
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
@@ -1895,10 +1880,10 @@ describe('Settings Loading and Merging', () => {
process.argv.push('-s');
try {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = createTestMergedSettings({
tools: { sandbox: false },
});
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Ensure sandbox is NOT in settings to test argv sniffing
settings.merged.tools.sandbox = undefined;
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
expect(process.env['TESTTEST']).not.toEqual('1234');
@@ -2797,7 +2782,7 @@ describe('Settings Loading and Merging', () => {
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toEqual('secret');
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
});
it('should NOT be tricked by positional arguments that look like flags', () => {
@@ -2816,7 +2801,7 @@ describe('Settings Loading and Merging', () => {
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toEqual('secret');
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
});
});
+4
View File
@@ -573,6 +573,10 @@ export function loadEnvironment(
relevantArgs.includes('-s') ||
relevantArgs.includes('--sandbox');
if (trustResult.isTrusted !== true && !isSandboxed) {
return;
}
// Cloud Shell environment variable handling
if (process.env['CLOUD_SHELL'] === 'true') {
setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed);
-10
View File
@@ -285,16 +285,6 @@ const SETTINGS_SCHEMA = {
'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.',
showInDialog: true,
},
modelRouting: {
type: 'boolean',
label: 'Plan Model Routing',
category: 'General',
requiresRestart: false,
default: true,
description:
'Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase.',
showInDialog: true,
},
},
},
retryFetchErrors: {
@@ -47,8 +47,6 @@ export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
setRemoteAdminSettings: vi.fn(),
isYoloModeDisabled: vi.fn(() => false),
isPlanEnabled: vi.fn(() => false),
getPlanModeRoutingEnabled: vi.fn().mockResolvedValue(true),
getApprovedPlanPath: vi.fn(() => undefined),
getCoreTools: vi.fn(() => []),
getAllowedTools: vi.fn(() => []),
getApprovalMode: vi.fn(() => 'default'),
-7
View File
@@ -150,7 +150,6 @@ import { useSettings } from './contexts/SettingsContext.js';
import { terminalCapabilityManager } from './utils/terminalCapabilityManager.js';
import { useInputHistoryStore } from './hooks/useInputHistoryStore.js';
import { useBanner } from './hooks/useBanner.js';
import { useTerminalSetupPrompt } from './utils/terminalSetup.js';
import { useHookDisplayState } from './hooks/useHookDisplayState.js';
import { useBackgroundShellManager } from './hooks/useBackgroundShellManager.js';
import {
@@ -607,12 +606,6 @@ export const AppContainer = (props: AppContainerProps) => {
initializeFromLogger(logger);
}, [logger, initializeFromLogger]);
// One-time prompt to suggest running /terminal-setup when it would help.
useTerminalSetupPrompt({
addConfirmUpdateExtensionRequest,
addItem: historyManager.addItem,
});
const refreshStatic = useCallback(() => {
if (!isAlternateBuffer) {
stdout.write(ansiEscapes.clearTerminal);
+3 -1
View File
@@ -250,7 +250,9 @@ export function AuthDialog({
</Box>
<Box marginTop={1}>
<Text color={theme.text.link}>
{'https://geminicli.com/docs/resources/tos-privacy/'}
{
'https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md'
}
</Text>
</Box>
</Box>
@@ -15,7 +15,7 @@ exports[`AuthDialog > Snapshots > renders correctly with auth error 1`] = `
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
│ https://geminicli.com/docs/resources/tos-privacy/
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -34,7 +34,7 @@ exports[`AuthDialog > Snapshots > renders correctly with default props 1`] = `
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
│ https://geminicli.com/docs/resources/tos-privacy/
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -53,7 +53,7 @@ exports[`AuthDialog > Snapshots > renders correctly with enforced auth type 1`]
│ │
│ Terms of Services and Privacy Notice for Gemini CLI │
│ │
│ https://geminicli.com/docs/resources/tos-privacy/
│ https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
"
@@ -23,17 +23,15 @@ import { getUrlOpenCommand } from '../../ui/utils/commandUtils.js';
import { debugLogger } from '@google/gemini-cli-core';
export const GITHUB_WORKFLOW_PATHS = [
'gemini-assistant/gemini-invoke.yml',
'gemini-assistant/gemini-plan-execute.yml',
'gemini-dispatch/gemini-dispatch.yml',
'issue-triage/gemini-scheduled-triage.yml',
'gemini-assistant/gemini-invoke.yml',
'issue-triage/gemini-triage.yml',
'issue-triage/gemini-scheduled-triage.yml',
'pr-review/gemini-review.yml',
];
export const GITHUB_COMMANDS_PATHS = [
'gemini-assistant/gemini-invoke.toml',
'gemini-assistant/gemini-plan-execute.toml',
'issue-triage/gemini-scheduled-triage.toml',
'issue-triage/gemini-triage.toml',
'pr-review/gemini-review.toml',
@@ -25,15 +25,15 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -72,15 +72,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -119,15 +119,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false* │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -166,15 +166,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -213,15 +213,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -260,15 +260,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ ▼ │
│ │
│ > Apply To │
@@ -307,15 +307,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -354,15 +354,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging false │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -401,15 +401,15 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin
│ Plan Directory undefined │
│ The directory where planning artifacts are stored. If not specified, defaults t… │
│ │
│ Plan Model Routing true │
│ Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pr… │
│ │
│ Max Chat Model Attempts 10 │
│ Maximum number of attempts for requests to the main chat model. Cannot exceed 10. │
│ │
│ Debug Keystroke Logging true* │
│ Enable debug logging of keystrokes to the console. │
│ │
│ Enable Session Cleanup false │
│ Enable automatic session cleanup │
│ │
│ ▼ │
│ │
│ Apply To │
@@ -39,7 +39,6 @@ import {
coreEvents,
CoreEvent,
MCPDiscoveryState,
getPlanModeExitMessage,
} from '@google/gemini-cli-core';
import type { Part, PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -2080,34 +2079,6 @@ describe('useGeminiStream', () => {
expect.objectContaining({ correlationId: 'corr-call2' }),
);
});
it('should inject a notification message when manually exiting Plan Mode', async () => {
// Setup mockConfig to return PLAN mode initially
(mockConfig.getApprovalMode as Mock).mockReturnValue(ApprovalMode.PLAN);
// Render the hook, which will initialize the previousApprovalModeRef with PLAN
const { result, client } = renderTestHook([]);
// Update mockConfig to return DEFAULT mode (new mode)
(mockConfig.getApprovalMode as Mock).mockReturnValue(
ApprovalMode.DEFAULT,
);
await act(async () => {
// Trigger manual exit from Plan Mode
await result.current.handleApprovalModeChange(ApprovalMode.DEFAULT);
});
// Verify that addHistory was called with the notification message
expect(client.addHistory).toHaveBeenCalledWith({
role: 'user',
parts: [
{
text: getPlanModeExitMessage(ApprovalMode.DEFAULT, true),
},
],
});
});
});
describe('handleFinishedEvent', () => {
+1 -33
View File
@@ -36,7 +36,6 @@ import {
CoreToolCallStatus,
buildUserSteeringHintPrompt,
generateSteeringAckMessage,
getPlanModeExitMessage,
} from '@google/gemini-cli-core';
import type {
Config,
@@ -204,9 +203,6 @@ export const useGeminiStream = (
const abortControllerRef = useRef<AbortController | null>(null);
const turnCancelledRef = useRef(false);
const activeQueryIdRef = useRef<string | null>(null);
const previousApprovalModeRef = useRef<ApprovalMode>(
config.getApprovalMode(),
);
const [isResponding, setIsResponding] = useState<boolean>(false);
const [thought, thoughtRef, setThought] =
useStateAndRef<ThoughtSummary | null>(null);
@@ -1439,34 +1435,6 @@ export const useGeminiStream = (
const handleApprovalModeChange = useCallback(
async (newApprovalMode: ApprovalMode) => {
if (
previousApprovalModeRef.current === ApprovalMode.PLAN &&
newApprovalMode !== ApprovalMode.PLAN &&
streamingState === StreamingState.Idle
) {
if (geminiClient) {
try {
await geminiClient.addHistory({
role: 'user',
parts: [
{
text: getPlanModeExitMessage(newApprovalMode, true),
},
],
});
} catch (error) {
onDebugMessage(
`Failed to notify model of Plan Mode exit: ${getErrorMessage(error)}`,
);
addItem({
type: MessageType.ERROR,
text: 'Failed to update the model about exiting Plan Mode. The model might be out of sync. Please consider restarting the session if you see unexpected behavior.',
});
}
}
}
previousApprovalModeRef.current = newApprovalMode;
// Auto-approve pending tool calls when switching to auto-approval modes
if (
newApprovalMode === ApprovalMode.YOLO ||
@@ -1505,7 +1473,7 @@ export const useGeminiStream = (
}
}
},
[config, toolCalls, geminiClient, streamingState, addItem, onDebugMessage],
[config, toolCalls],
);
const handleCompletedTools = useCallback(
@@ -5,12 +5,7 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
terminalSetup,
VSCODE_SHIFT_ENTER_SEQUENCE,
shouldPromptForTerminalSetup,
} from './terminalSetup.js';
import { terminalCapabilityManager } from './terminalCapabilityManager.js';
import { terminalSetup, VSCODE_SHIFT_ENTER_SEQUENCE } from './terminalSetup.js';
// Mock dependencies
const mocks = vi.hoisted(() => ({
@@ -200,51 +195,4 @@ describe('terminalSetup', () => {
expect(mocks.writeFile).toHaveBeenCalled();
});
});
describe('shouldPromptForTerminalSetup', () => {
it('should return false when kitty protocol is already enabled', async () => {
vi.mocked(
terminalCapabilityManager.isKittyProtocolEnabled,
).mockReturnValue(true);
const result = await shouldPromptForTerminalSetup();
expect(result).toBe(false);
});
it('should return false when both Shift+Enter and Ctrl+Enter bindings already exist', async () => {
vi.mocked(
terminalCapabilityManager.isKittyProtocolEnabled,
).mockReturnValue(false);
process.env['TERM_PROGRAM'] = 'vscode';
const existingBindings = [
{
key: 'shift+enter',
command: 'workbench.action.terminal.sendSequence',
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
},
{
key: 'ctrl+enter',
command: 'workbench.action.terminal.sendSequence',
args: { text: VSCODE_SHIFT_ENTER_SEQUENCE },
},
];
mocks.readFile.mockResolvedValue(JSON.stringify(existingBindings));
const result = await shouldPromptForTerminalSetup();
expect(result).toBe(false);
});
it('should return true when keybindings file does not exist', async () => {
vi.mocked(
terminalCapabilityManager.isKittyProtocolEnabled,
).mockReturnValue(false);
process.env['TERM_PROGRAM'] = 'vscode';
mocks.readFile.mockRejectedValue(new Error('ENOENT'));
const result = await shouldPromptForTerminalSetup();
expect(result).toBe(true);
});
});
});
+38 -184
View File
@@ -32,13 +32,6 @@ import { promisify } from 'node:util';
import { terminalCapabilityManager } from './terminalCapabilityManager.js';
import { debugLogger, homedir } from '@google/gemini-cli-core';
import { useEffect } from 'react';
import { persistentState } from '../../utils/persistentState.js';
import { requestConsentInteractive } from '../../config/extensions/consent.js';
import type { ConfirmationRequest } from '../types.js';
import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js';
type AddItemFn = UseHistoryManagerReturn['addItem'];
export const VSCODE_SHIFT_ENTER_SEQUENCE = '\\\r\n';
@@ -61,56 +54,6 @@ export interface TerminalSetupResult {
type SupportedTerminal = 'vscode' | 'cursor' | 'windsurf' | 'antigravity';
/**
* Terminal metadata used for configuration.
*/
interface TerminalData {
terminalName: string;
appName: string;
}
const TERMINAL_DATA: Record<SupportedTerminal, TerminalData> = {
vscode: { terminalName: 'VS Code', appName: 'Code' },
cursor: { terminalName: 'Cursor', appName: 'Cursor' },
windsurf: { terminalName: 'Windsurf', appName: 'Windsurf' },
antigravity: { terminalName: 'Antigravity', appName: 'Antigravity' },
};
/**
* Maps a supported terminal ID to its display name and config folder name.
*/
function getSupportedTerminalData(
terminal: SupportedTerminal,
): TerminalData | null {
return TERMINAL_DATA[terminal] || null;
}
type Keybinding = {
key?: string;
command?: string;
args?: { text?: string };
};
function isKeybinding(kb: unknown): kb is Keybinding {
return typeof kb === 'object' && kb !== null;
}
/**
* Checks if a keybindings array contains our specific binding for a given key.
*/
function hasOurBinding(
keybindings: unknown[],
key: 'shift+enter' | 'ctrl+enter',
): boolean {
return keybindings.some((kb) => {
if (!isKeybinding(kb)) return false;
return (
kb.key === key &&
kb.command === 'workbench.action.terminal.sendSequence' &&
kb.args?.text === VSCODE_SHIFT_ENTER_SEQUENCE
);
});
}
export function getTerminalProgram(): SupportedTerminal | null {
const termProgram = process.env['TERM_PROGRAM'];
@@ -303,17 +246,23 @@ async function configureVSCodeStyle(
const results = targetBindings.map((target) => {
const hasOurBinding = keybindings.some((kb) => {
if (!isKeybinding(kb)) return false;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const binding = kb as {
command?: string;
args?: { text?: string };
key?: string;
};
return (
kb.key === target.key &&
kb.command === target.command &&
kb.args?.text === target.args.text
binding.key === target.key &&
binding.command === target.command &&
binding.args?.text === target.args.text
);
});
const existingBinding = keybindings.find((kb) => {
if (!isKeybinding(kb)) return false;
return kb.key === target.key;
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const binding = kb as { key?: string };
return binding.key === target.key;
});
return {
@@ -367,57 +316,22 @@ async function configureVSCodeStyle(
}
}
/**
* Determines whether it is useful to prompt the user to run /terminal-setup
* in the current environment.
*
* Returns true when:
* - Kitty/modifyOtherKeys keyboard protocol is not already enabled, and
* - We're running inside a supported terminal (VS Code, Cursor, Windsurf, Antigravity), and
* - The keybindings file either does not exist or does not already contain both
* of our Shift+Enter and Ctrl+Enter bindings.
*/
export async function shouldPromptForTerminalSetup(): Promise<boolean> {
if (terminalCapabilityManager.isKittyProtocolEnabled()) {
return false;
}
// Terminal-specific configuration functions
const terminal = await detectTerminal();
if (!terminal) {
return false;
}
async function configureVSCode(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('VS Code', 'Code');
}
const terminalData = getSupportedTerminalData(terminal);
if (!terminalData) {
return false;
}
async function configureCursor(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('Cursor', 'Cursor');
}
const configDir = getVSCodeStyleConfigDir(terminalData.appName);
if (!configDir) {
return false;
}
async function configureWindsurf(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('Windsurf', 'Windsurf');
}
const keybindingsFile = path.join(configDir, 'keybindings.json');
try {
const content = await fs.readFile(keybindingsFile, 'utf8');
const cleanContent = stripJsonComments(content);
const parsedContent: unknown = JSON.parse(cleanContent) as unknown;
if (!Array.isArray(parsedContent)) {
return true;
}
const hasOurShiftEnter = hasOurBinding(parsedContent, 'shift+enter');
const hasOurCtrlEnter = hasOurBinding(parsedContent, 'ctrl+enter');
return !(hasOurShiftEnter && hasOurCtrlEnter);
} catch (error) {
debugLogger.debug(
`Failed to read or parse keybindings, assuming prompt is needed: ${error}`,
);
return true;
}
async function configureAntigravity(): Promise<TerminalSetupResult> {
return configureVSCodeStyle('Antigravity', 'Antigravity');
}
/**
@@ -459,79 +373,19 @@ export async function terminalSetup(): Promise<TerminalSetupResult> {
};
}
const terminalData = getSupportedTerminalData(terminal);
if (!terminalData) {
return {
success: false,
message: `Terminal "${terminal}" is not supported yet.`,
};
switch (terminal) {
case 'vscode':
return configureVSCode();
case 'cursor':
return configureCursor();
case 'windsurf':
return configureWindsurf();
case 'antigravity':
return configureAntigravity();
default:
return {
success: false,
message: `Terminal "${terminal}" is not supported yet.`,
};
}
return configureVSCodeStyle(terminalData.terminalName, terminalData.appName);
}
export const TERMINAL_SETUP_CONSENT_MESSAGE =
'Gemini CLI works best with Shift+Enter/Ctrl+Enter for multiline input. ' +
'Would you like to automatically configure your terminal keybindings?';
export function formatTerminalSetupResultMessage(
result: TerminalSetupResult,
): string {
let content = result.message;
if (result.requiresRestart) {
content +=
'\n\nPlease restart your terminal for the changes to take effect.';
}
return content;
}
interface UseTerminalSetupPromptParams {
addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void;
addItem: AddItemFn;
}
/**
* Hook that shows a one-time prompt to run /terminal-setup when it would help.
*/
export function useTerminalSetupPrompt({
addConfirmUpdateExtensionRequest,
addItem,
}: UseTerminalSetupPromptParams): void {
useEffect(() => {
const hasBeenPrompted = persistentState.get('terminalSetupPromptShown');
if (hasBeenPrompted) {
return;
}
let cancelled = false;
// eslint-disable-next-line @typescript-eslint/no-floating-promises
(async () => {
const shouldPrompt = await shouldPromptForTerminalSetup();
if (!shouldPrompt || cancelled) return;
persistentState.set('terminalSetupPromptShown', true);
const confirmed = await requestConsentInteractive(
TERMINAL_SETUP_CONSENT_MESSAGE,
addConfirmUpdateExtensionRequest,
);
if (!confirmed || cancelled) return;
const result = await terminalSetup();
if (cancelled) return;
addItem(
{
type: result.success ? 'info' : 'error',
text: formatTerminalSetupResultMessage(result),
},
Date.now(),
);
})();
return () => {
cancelled = true;
};
}, [addConfirmUpdateExtensionRequest, addItem]);
}
+2 -2
View File
@@ -48,12 +48,12 @@ describe('textUtils', () => {
it('should handle unicode characters that crash string-width', () => {
// U+0602 caused string-width to crash (see #16418)
const char = '؂';
expect(getCachedStringWidth(char)).toBe(0);
expect(getCachedStringWidth(char)).toBe(1);
});
it('should handle unicode characters that crash string-width with ANSI codes', () => {
const charWithAnsi = '\u001b[31m' + '؂' + '\u001b[0m';
expect(getCachedStringWidth(charWithAnsi)).toBe(0);
expect(getCachedStringWidth(charWithAnsi)).toBe(1);
});
});
@@ -12,7 +12,6 @@ const STATE_FILENAME = 'state.json';
interface PersistentStateData {
defaultBannerShownCount?: Record<string, number>;
terminalSetupPromptShown?: boolean;
tipsShown?: number;
hasSeenScreenReaderNudge?: boolean;
focusUiEnabled?: boolean;
@@ -177,14 +177,6 @@ describe('GeminiAgent', () => {
expect(response.protocolVersion).toBe(acp.PROTOCOL_VERSION);
expect(response.authMethods).toHaveLength(3);
const geminiAuth = response.authMethods?.find(
(m) => m.id === AuthType.USE_GEMINI,
);
expect(geminiAuth?._meta).toEqual({
'api-key': {
provider: 'google',
},
});
expect(response.agentCapabilities?.loadSession).toBe(true);
});
@@ -195,7 +187,6 @@ describe('GeminiAgent', () => {
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.LOGIN_WITH_GOOGLE,
undefined,
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
@@ -204,25 +195,6 @@ describe('GeminiAgent', () => {
);
});
it('should authenticate correctly with api-key in _meta', async () => {
await agent.authenticate({
methodId: AuthType.USE_GEMINI,
_meta: {
'api-key': 'test-api-key',
},
} as unknown as acp.AuthenticateRequest);
expect(mockConfig.refreshAuth).toHaveBeenCalledWith(
AuthType.USE_GEMINI,
'test-api-key',
);
expect(mockSettings.setValue).toHaveBeenCalledWith(
SettingScope.User,
'security.auth.selectedType',
AuthType.USE_GEMINI,
);
});
it('should create a new session', async () => {
mockConfig.getContentGeneratorConfig = vi.fn().mockReturnValue({
apiKey: 'test-key',
@@ -37,17 +37,12 @@ import {
partListUnionToString,
LlmRole,
ApprovalMode,
getVersion,
convertSessionToClientHistory,
} from '@google/gemini-cli-core';
import * as acp from '@agentclientprotocol/sdk';
import { AcpFileSystemService } from './fileSystemService.js';
import { getAcpErrorMessage } from './acpErrors.js';
import { Readable, Writable } from 'node:stream';
function hasMeta(obj: unknown): obj is { _meta?: Record<string, unknown> } {
return typeof obj === 'object' && obj !== null && '_meta' in obj;
}
import type { Content, Part, FunctionCall } from '@google/genai';
import type { LoadedSettings } from '../config/settings.js';
import { SettingScope, loadSettings } from '../config/settings.js';
@@ -86,7 +81,6 @@ export async function runZedIntegration(
export class GeminiAgent {
private sessions: Map<string, Session> = new Map();
private clientCapabilities: acp.ClientCapabilities | undefined;
private apiKey: string | undefined;
constructor(
private config: Config,
@@ -103,35 +97,25 @@ export class GeminiAgent {
{
id: AuthType.LOGIN_WITH_GOOGLE,
name: 'Log in with Google',
description: 'Log in with your Google account',
description: null,
},
{
id: AuthType.USE_GEMINI,
name: 'Gemini API key',
description: 'Use an API key with Gemini Developer API',
_meta: {
'api-key': {
provider: 'google',
},
},
name: 'Use Gemini API key',
description:
'Requires setting the `GEMINI_API_KEY` environment variable',
},
{
id: AuthType.USE_VERTEX_AI,
name: 'Vertex AI',
description: 'Use an API key with Vertex AI GenAI API',
description: null,
},
];
await this.config.initialize();
const version = await getVersion();
return {
protocolVersion: acp.PROTOCOL_VERSION,
authMethods,
agentInfo: {
name: 'gemini-cli',
title: 'Gemini CLI',
version,
},
agentCapabilities: {
loadSession: true,
promptCapabilities: {
@@ -147,8 +131,7 @@ export class GeminiAgent {
};
}
async authenticate(req: acp.AuthenticateRequest): Promise<void> {
const { methodId } = req;
async authenticate({ methodId }: acp.AuthenticateRequest): Promise<void> {
const method = z.nativeEnum(AuthType).parse(methodId);
const selectedAuthType = this.settings.merged.security.auth.selectedType;
@@ -156,21 +139,17 @@ export class GeminiAgent {
if (selectedAuthType && selectedAuthType !== method) {
await clearCachedCredentialFile();
}
// Check for api-key in _meta
const meta = hasMeta(req) ? req._meta : undefined;
const apiKey =
typeof meta?.['api-key'] === 'string' ? meta['api-key'] : undefined;
// Refresh auth with the requested method
// This will reuse existing credentials if they're valid,
// or perform new authentication if needed
try {
if (apiKey) {
this.apiKey = apiKey;
}
await this.config.refreshAuth(method, apiKey ?? this.apiKey);
await this.config.refreshAuth(method);
} catch (e) {
throw new acp.RequestError(-32000, getAcpErrorMessage(e));
throw new acp.RequestError(
getErrorStatus(e) || 401,
getAcpErrorMessage(e),
);
}
this.settings.setValue(
SettingScope.User,
@@ -198,7 +177,7 @@ export class GeminiAgent {
let isAuthenticated = false;
let authErrorMessage = '';
try {
await config.refreshAuth(authType, this.apiKey);
await config.refreshAuth(authType);
isAuthenticated = true;
// Extra validation for Gemini API key
@@ -220,7 +199,7 @@ export class GeminiAgent {
if (!isAuthenticated) {
throw new acp.RequestError(
-32000,
401,
authErrorMessage || 'Authentication required.',
);
}
@@ -323,7 +302,7 @@ export class GeminiAgent {
// This satisfies the security requirement to verify the user before executing
// potentially unsafe server definitions.
try {
await config.refreshAuth(selectedAuthType, this.apiKey);
await config.refreshAuth(selectedAuthType);
} catch (e) {
debugLogger.error(`Authentication failed: ${e}`);
throw acp.RequestError.authRequired();
@@ -95,7 +95,6 @@ const mockConfig = {
getNoBrowser: () => false,
getProxy: () => 'http://test.proxy.com:8080',
isBrowserLaunchSuppressed: () => false,
getExperimentalZedIntegration: () => false,
} as unknown as Config;
// Mock fetch globally
+3 -6
View File
@@ -271,12 +271,9 @@ async function initOauthClient(
await triggerPostAuthCallbacks(client.credentials);
} else {
// In Zed integration, we skip the interactive consent and directly open the browser
if (!config.getExperimentalZedIntegration()) {
const userConsent = await getConsentForOauth('');
if (!userConsent) {
throw new FatalCancellationError('Authentication cancelled by user.');
}
const userConsent = await getConsentForOauth('');
if (!userConsent) {
throw new FatalCancellationError('Authentication cancelled by user.');
}
const webLogin = await authWithWeb(client);
+2 -26
View File
@@ -499,7 +499,6 @@ describe('Server Config (config.ts)', () => {
expect(createContentGeneratorConfig).toHaveBeenCalledWith(
config,
authType,
undefined,
);
// Verify that contentGeneratorConfig is updated
expect(config.getContentGeneratorConfig()).toEqual(mockContentConfig);
@@ -1929,7 +1928,7 @@ describe('Config getHooks', () => {
const mockHooks = {
BeforeTool: [
{
hooks: [{ type: HookType.Command, command: 'echo 1' } as const],
hooks: [{ type: HookType.Command, command: 'echo 1' }],
},
],
};
@@ -2236,7 +2235,7 @@ describe('Hooks configuration', () => {
const initialHooks = {
BeforeAgent: [
{
hooks: [{ type: HookType.Command as const, command: 'initial' }],
hooks: [{ type: HookType.Command, command: 'initial' }],
},
],
};
@@ -2534,29 +2533,6 @@ describe('Config Quota & Preview Model Access', () => {
expect(config.isPlanEnabled()).toBe(false);
});
});
describe('getPlanModeRoutingEnabled', () => {
it('should default to true when not provided', async () => {
const config = new Config(baseParams);
expect(await config.getPlanModeRoutingEnabled()).toBe(true);
});
it('should return true when explicitly enabled in planSettings', async () => {
const config = new Config({
...baseParams,
planSettings: { modelRouting: true },
});
expect(await config.getPlanModeRoutingEnabled()).toBe(true);
});
it('should return false when explicitly disabled in planSettings', async () => {
const config = new Config({
...baseParams,
planSettings: { modelRouting: false },
});
expect(await config.getPlanModeRoutingEnabled()).toBe(false);
});
});
});
describe('Config JIT Initialization', () => {
+16 -10
View File
@@ -106,7 +106,12 @@ import { FileExclusions } from '../utils/ignorePatterns.js';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import type { EventEmitter } from 'node:events';
import { PolicyEngine } from '../policy/policy-engine.js';
import { ApprovalMode, type PolicyEngineConfig } from '../policy/types.js';
import {
ApprovalMode,
type PolicyEngineConfig,
type PolicyRule,
type SafetyCheckerRule,
} from '../policy/types.js';
import { HookSystem } from '../hooks/index.js';
import type {
UserTierId,
@@ -153,7 +158,6 @@ export interface SummarizeToolOutputSettings {
export interface PlanSettings {
directory?: string;
modelRouting?: boolean;
}
export interface TelemetrySettings {
@@ -304,6 +308,7 @@ export interface GeminiCLIExtension {
mcpServers?: Record<string, MCPServerConfig>;
contextFiles: string[];
excludeTools?: string[];
plan?: PlanSettings;
id: string;
hooks?: { [K in HookEventName]?: HookDefinition[] };
settings?: ExtensionSetting[];
@@ -315,6 +320,14 @@ export interface GeminiCLIExtension {
* These themes will be registered when the extension is activated.
*/
themes?: CustomTheme[];
/**
* Policy rules contributed by this extension.
*/
rules?: PolicyRule[];
/**
* Safety checkers contributed by this extension.
*/
checkers?: SafetyCheckerRule[];
}
export interface ExtensionInstallMetadata {
@@ -735,7 +748,6 @@ export class Config {
private readonly experimentalJitContext: boolean;
private readonly disableLLMCorrection: boolean;
private readonly planEnabled: boolean;
private readonly planModeRoutingEnabled: boolean;
private readonly modelSteering: boolean;
private contextManager?: ContextManager;
private terminalBackground: string | undefined = undefined;
@@ -825,7 +837,6 @@ export class Config {
this.agents = params.agents ?? {};
this.disableLLMCorrection = params.disableLLMCorrection ?? true;
this.planEnabled = params.plan ?? false;
this.planModeRoutingEnabled = params.planSettings?.modelRouting ?? true;
this.enableEventDrivenScheduler = params.enableEventDrivenScheduler ?? true;
this.skillsSupport = params.skillsSupport ?? true;
this.disabledSkills = params.disabledSkills ?? [];
@@ -1126,7 +1137,7 @@ export class Config {
return this.contentGenerator;
}
async refreshAuth(authMethod: AuthType, apiKey?: string) {
async refreshAuth(authMethod: AuthType) {
// Reset availability service when switching auth
this.modelAvailabilityService.reset();
@@ -1152,7 +1163,6 @@ export class Config {
const newContentGeneratorConfig = await createContentGeneratorConfig(
this,
authMethod,
apiKey,
);
this.contentGenerator = await createContentGenerator(
newContentGeneratorConfig,
@@ -2322,10 +2332,6 @@ export class Config {
return this.experiments?.flags[ExperimentFlags.USER_CACHING]?.boolValue;
}
async getPlanModeRoutingEnabled(): Promise<boolean> {
return this.planModeRoutingEnabled;
}
async getNumericalRoutingEnabled(): Promise<boolean> {
await this.ensureExperimentsLoaded();
+1 -5
View File
@@ -90,13 +90,9 @@ export type ContentGeneratorConfig = {
export async function createContentGeneratorConfig(
config: Config,
authType: AuthType | undefined,
apiKey?: string,
): Promise<ContentGeneratorConfig> {
const geminiApiKey =
apiKey ||
process.env['GEMINI_API_KEY'] ||
(await loadApiKey()) ||
undefined;
process.env['GEMINI_API_KEY'] || (await loadApiKey()) || undefined;
const googleApiKey = process.env['GOOGLE_API_KEY'] || undefined;
const googleCloudProject =
process.env['GOOGLE_CLOUD_PROJECT'] ||
@@ -24,16 +24,11 @@ vi.mock('../telemetry/trace.js', () => ({
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import type {
Content,
GenerateContentConfig,
GenerateContentResponse,
EmbedContentResponse,
} from '@google/genai';
import type { ContentGenerator } from './contentGenerator.js';
import {
LoggingContentGenerator,
estimateContextBreakdown,
} from './loggingContentGenerator.js';
import { LoggingContentGenerator } from './loggingContentGenerator.js';
import type { Config } from '../config/config.js';
import { UserTierId } from '../code_assist/types.js';
import { ApiRequestEvent, LlmRole } from '../telemetry/types.js';
@@ -351,280 +346,3 @@ describe('LoggingContentGenerator', () => {
});
});
});
describe('estimateContextBreakdown', () => {
it('should return zeros for empty contents and no config', () => {
const result = estimateContextBreakdown([], undefined);
expect(result).toEqual({
system_instructions: 0,
tool_definitions: 0,
history: 0,
tool_calls: {},
mcp_servers: 0,
});
});
it('should estimate system instruction tokens', () => {
const config = {
systemInstruction: 'You are a helpful assistant.',
} as GenerateContentConfig;
const result = estimateContextBreakdown([], config);
expect(result.system_instructions).toBeGreaterThan(0);
expect(result.tool_definitions).toBe(0);
expect(result.history).toBe(0);
});
it('should estimate non-MCP tool definition tokens', () => {
const config = {
tools: [
{
functionDeclarations: [
{ name: 'read_file', description: 'Reads a file', parameters: {} },
],
},
],
} as unknown as GenerateContentConfig;
const result = estimateContextBreakdown([], config);
expect(result.tool_definitions).toBeGreaterThan(0);
expect(result.mcp_servers).toBe(0);
});
it('should classify MCP tool definitions into mcp_servers, not tool_definitions', () => {
const config = {
tools: [
{
functionDeclarations: [
{
name: 'myserver__search',
description: 'Search via MCP',
parameters: {},
},
{
name: 'read_file',
description: 'Reads a file',
parameters: {},
},
],
},
],
} as unknown as GenerateContentConfig;
const result = estimateContextBreakdown([], config);
expect(result.mcp_servers).toBeGreaterThan(0);
expect(result.tool_definitions).toBeGreaterThan(0);
// MCP tokens should not be in tool_definitions
const configOnlyBuiltin = {
tools: [
{
functionDeclarations: [
{
name: 'read_file',
description: 'Reads a file',
parameters: {},
},
],
},
],
} as unknown as GenerateContentConfig;
const builtinOnly = estimateContextBreakdown([], configOnlyBuiltin);
// tool_definitions should be smaller when MCP tools are separated out
expect(result.tool_definitions).toBeLessThan(
result.tool_definitions + result.mcp_servers,
);
expect(builtinOnly.mcp_servers).toBe(0);
});
it('should not classify tools with __ in the middle of a segment as MCP', () => {
// "__" at start or end (not a valid server__tool pattern) should not be MCP
const config = {
tools: [
{
functionDeclarations: [
{ name: '__leading', description: 'test', parameters: {} },
{ name: 'trailing__', description: 'test', parameters: {} },
{
name: 'a__b__c',
description: 'three parts - not valid MCP',
parameters: {},
},
],
},
],
} as unknown as GenerateContentConfig;
const result = estimateContextBreakdown([], config);
expect(result.mcp_servers).toBe(0);
});
it('should estimate history tokens excluding tool call/response parts', () => {
const contents: Content[] = [
{ role: 'user', parts: [{ text: 'Hello world' }] },
{ role: 'model', parts: [{ text: 'Hi there!' }] },
];
const result = estimateContextBreakdown(contents);
expect(result.history).toBeGreaterThan(0);
expect(result.tool_calls).toEqual({});
});
it('should separate tool call tokens from history', () => {
const contents: Content[] = [
{
role: 'model',
parts: [
{
functionCall: {
name: 'read_file',
args: { path: '/tmp/test.txt' },
},
},
],
},
{
role: 'function',
parts: [
{
functionResponse: {
name: 'read_file',
response: { content: 'file contents here' },
},
},
],
},
];
const result = estimateContextBreakdown(contents);
expect(result.tool_calls['read_file']).toBeGreaterThan(0);
// history should be zero since all parts are tool calls
expect(result.history).toBe(0);
});
it('should produce additive (non-overlapping) fields', () => {
const contents: Content[] = [
{ role: 'user', parts: [{ text: 'Hello' }] },
{
role: 'model',
parts: [
{
functionCall: {
name: 'read_file',
args: { path: '/tmp/test.txt' },
},
},
],
},
{
role: 'function',
parts: [
{
functionResponse: {
name: 'read_file',
response: { content: 'data' },
},
},
],
},
];
const config = {
systemInstruction: 'Be helpful.',
tools: [
{
functionDeclarations: [
{ name: 'read_file', description: 'Read', parameters: {} },
{
name: 'myserver__search',
description: 'MCP search',
parameters: {},
},
],
},
],
} as unknown as GenerateContentConfig;
const result = estimateContextBreakdown(contents, config);
// All fields should be non-overlapping
expect(result.system_instructions).toBeGreaterThan(0);
expect(result.tool_definitions).toBeGreaterThan(0);
expect(result.history).toBeGreaterThan(0);
// tool_calls should only contain non-MCP tools
expect(result.tool_calls['read_file']).toBeGreaterThan(0);
expect(result.tool_calls['myserver__search']).toBeUndefined();
// MCP tokens are only in mcp_servers
expect(result.mcp_servers).toBeGreaterThan(0);
});
it('should classify MCP tool calls into mcp_servers only, not tool_calls', () => {
const contents: Content[] = [
{
role: 'model',
parts: [
{
functionCall: {
name: 'myserver__search',
args: { query: 'test' },
},
},
],
},
{
role: 'function',
parts: [
{
functionResponse: {
name: 'myserver__search',
response: { results: [] },
},
},
],
},
];
const result = estimateContextBreakdown(contents);
// MCP tool calls should NOT appear in tool_calls
expect(result.tool_calls['myserver__search']).toBeUndefined();
// MCP call tokens should only be counted in mcp_servers
expect(result.mcp_servers).toBeGreaterThan(0);
});
it('should handle mixed MCP and non-MCP tool calls', () => {
const contents: Content[] = [
{
role: 'model',
parts: [
{
functionCall: {
name: 'read_file',
args: { path: '/test' },
},
},
{
functionCall: {
name: 'myserver__search',
args: { q: 'hello' },
},
},
],
},
];
const result = estimateContextBreakdown(contents);
// Non-MCP tools should be in tool_calls
expect(result.tool_calls['read_file']).toBeGreaterThan(0);
// MCP tools should NOT be in tool_calls
expect(result.tool_calls['myserver__search']).toBeUndefined();
// MCP tool calls should only be in mcp_servers
expect(result.mcp_servers).toBeGreaterThan(0);
});
it('should use "unknown" for tool calls without a name', () => {
const contents: Content[] = [
{
role: 'model',
parts: [
{
functionCall: {
name: undefined as unknown as string,
args: { x: 1 },
},
},
],
},
];
const result = estimateContextBreakdown(contents);
expect(result.tool_calls['unknown']).toBeGreaterThan(0);
});
});
+22 -125
View File
@@ -16,7 +16,7 @@ import type {
GenerateContentResponseUsageMetadata,
GenerateContentResponse,
} from '@google/genai';
import type { ServerDetails, ContextBreakdown } from '../telemetry/types.js';
import type { ServerDetails } from '../telemetry/types.js';
import {
ApiRequestEvent,
ApiResponseEvent,
@@ -37,104 +37,14 @@ import { isStructuredError } from '../utils/quotaErrorDetection.js';
import { runInDevTraceSpan, type SpanMetadata } from '../telemetry/trace.js';
import { debugLogger } from '../utils/debugLogger.js';
import { getErrorType } from '../utils/errors.js';
import { isMcpToolName } from '../tools/mcp-tool.js';
import { estimateTokenCountSync } from '../utils/tokenCalculation.js';
interface StructuredError {
status: number;
}
/**
* Rough token estimate for non-Part config objects (tool definitions, etc.)
* where estimateTokenCountSync cannot be used directly.
* A decorator that wraps a ContentGenerator to add logging to API calls.
*/
function estimateConfigTokens(value: unknown): number {
return Math.floor(JSON.stringify(value).length / 4);
}
/**
* Estimates the context breakdown for telemetry. All returned fields are
* additive (non-overlapping), so their sum approximates the total context size.
*
* - system_instructions: tokens from system instruction config
* - tool_definitions: tokens from non-MCP tool definitions
* - history: tokens from conversation history, excluding tool call/response parts
* - tool_calls: per-tool token counts for non-MCP function call + response parts
* - mcp_servers: tokens from MCP tool definitions + MCP tool call/response parts
*
* MCP tool calls are excluded from tool_calls and counted only in mcp_servers
* to keep fields non-overlapping and avoid leaking MCP server names in telemetry.
*/
export function estimateContextBreakdown(
contents: Content[],
config?: GenerateContentConfig,
): ContextBreakdown {
let systemInstructions = 0;
let toolDefinitions = 0;
let history = 0;
let mcpServers = 0;
const toolCalls: Record<string, number> = {};
if (config?.systemInstruction) {
systemInstructions += estimateConfigTokens(config.systemInstruction);
}
if (config?.tools) {
for (const tool of config.tools) {
const toolTokens = estimateConfigTokens(tool);
if (
tool &&
typeof tool === 'object' &&
'functionDeclarations' in tool &&
tool.functionDeclarations
) {
let mcpTokensInTool = 0;
for (const func of tool.functionDeclarations) {
if (func.name && isMcpToolName(func.name)) {
mcpTokensInTool += estimateConfigTokens(func);
}
}
mcpServers += mcpTokensInTool;
toolDefinitions += toolTokens - mcpTokensInTool;
} else {
toolDefinitions += toolTokens;
}
}
}
for (const content of contents) {
for (const part of content.parts || []) {
if (part.functionCall) {
const name = part.functionCall.name || 'unknown';
const tokens = estimateTokenCountSync([part]);
if (isMcpToolName(name)) {
mcpServers += tokens;
} else {
toolCalls[name] = (toolCalls[name] || 0) + tokens;
}
} else if (part.functionResponse) {
const name = part.functionResponse.name || 'unknown';
const tokens = estimateTokenCountSync([part]);
if (isMcpToolName(name)) {
mcpServers += tokens;
} else {
toolCalls[name] = (toolCalls[name] || 0) + tokens;
}
} else {
history += estimateTokenCountSync([part]);
}
}
}
return {
system_instructions: systemInstructions,
tool_definitions: toolDefinitions,
history,
tool_calls: toolCalls,
mcp_servers: mcpServers,
};
}
export class LoggingContentGenerator implements ContentGenerator {
constructor(
private readonly wrapped: ContentGenerator,
@@ -224,40 +134,27 @@ export class LoggingContentGenerator implements ContentGenerator {
generationConfig?: GenerateContentConfig,
serverDetails?: ServerDetails,
): void {
const event = new ApiResponseEvent(
model,
durationMs,
{
prompt_id,
contents: requestContents,
generate_content_config: generationConfig,
server: serverDetails,
},
{
candidates: responseCandidates,
response_id: responseId,
},
this.config.getContentGeneratorConfig()?.authType,
usageMetadata,
responseText,
role,
logApiResponse(
this.config,
new ApiResponseEvent(
model,
durationMs,
{
prompt_id,
contents: requestContents,
generate_content_config: generationConfig,
server: serverDetails,
},
{
candidates: responseCandidates,
response_id: responseId,
},
this.config.getContentGeneratorConfig()?.authType,
usageMetadata,
responseText,
role,
),
);
// Only compute context breakdown for turn-ending responses (when the user
// gets back control to type). If the response contains function calls, the
// model is in a tool-use loop and will make more API calls — skip to avoid
// emitting redundant cumulative snapshots for every intermediate step.
const hasToolCalls = responseCandidates?.some((c) =>
c.content?.parts?.some((p) => p.functionCall),
);
if (!hasToolCalls) {
event.usage.context_breakdown = estimateContextBreakdown(
requestContents,
generationConfig,
);
}
logApiResponse(this.config, event);
}
private _logApiError(
+3 -6
View File
@@ -8,7 +8,7 @@ import type { Config } from '../config/config.js';
import type { HookPlanner, HookEventContext } from './hookPlanner.js';
import type { HookRunner } from './hookRunner.js';
import type { HookAggregator, AggregatedHookResult } from './hookAggregator.js';
import { HookEventName, HookType } from './types.js';
import { HookEventName } from './types.js';
import type {
HookConfig,
HookInput,
@@ -500,10 +500,7 @@ export class HookEventHandler {
* Get hook name from config for display or telemetry
*/
private getHookName(config: HookConfig): string {
if (config.type === HookType.Command) {
return config.name || config.command || 'unknown-command';
}
return config.name || 'unknown-hook';
return config.name || config.command || 'unknown-command';
}
/**
@@ -516,7 +513,7 @@ export class HookEventHandler {
/**
* Get hook type from execution result for telemetry
*/
private getHookTypeFromResult(result: HookExecutionResult): HookType {
private getHookTypeFromResult(result: HookExecutionResult): 'command' {
return result.hookConfig.type;
}
}
+4 -11
View File
@@ -13,10 +13,9 @@ import {
HookEventName,
HookType,
HOOKS_CONFIG_FIELDS,
type CommandHookConfig,
type HookDefinition,
} from './types.js';
import type { Config } from '../config/config.js';
import type { HookDefinition } from './types.js';
// Mock fs
vi.mock('fs', () => ({
@@ -154,9 +153,7 @@ describe('HookRegistry', () => {
expect(hooks).toHaveLength(1);
expect(hooks[0].eventName).toBe(HookEventName.BeforeTool);
expect(hooks[0].config.type).toBe(HookType.Command);
expect((hooks[0].config as CommandHookConfig).command).toBe(
'./hooks/check_style.sh',
);
expect(hooks[0].config.command).toBe('./hooks/check_style.sh');
expect(hooks[0].matcher).toBe('EditTool');
expect(hooks[0].source).toBe(ConfigSource.Project);
});
@@ -189,9 +186,7 @@ describe('HookRegistry', () => {
expect(hooks).toHaveLength(1);
expect(hooks[0].eventName).toBe(HookEventName.AfterTool);
expect(hooks[0].config.type).toBe(HookType.Command);
expect((hooks[0].config as CommandHookConfig).command).toBe(
'./hooks/after-tool.sh',
);
expect(hooks[0].config.command).toBe('./hooks/after-tool.sh');
});
it('should handle invalid configuration gracefully', async () => {
@@ -637,9 +632,7 @@ describe('HookRegistry', () => {
// Should only load the valid hook
const hooks = hookRegistry.getAllHooks();
expect(hooks).toHaveLength(1);
expect((hooks[0].config as CommandHookConfig).command).toBe(
'./valid-hook.sh',
);
expect(hooks[0].config.command).toBe('./valid-hook.sh');
// Verify the warnings for invalid configurations
// 1st warning: non-object hookConfig ('invalid-string')
+3 -47
View File
@@ -34,40 +34,11 @@ export class HookRegistry {
this.config = config;
}
/**
* Register a new hook programmatically
*/
registerHook(
config: HookConfig,
eventName: HookEventName,
options?: { matcher?: string; sequential?: boolean; source?: ConfigSource },
): void {
const source = options?.source ?? ConfigSource.Runtime;
if (!this.validateHookConfig(config, eventName, source)) {
throw new Error(
`Invalid hook configuration for ${eventName} from ${source}`,
);
}
this.entries.push({
config,
source,
eventName,
matcher: options?.matcher,
sequential: options?.sequential,
enabled: true,
});
}
/**
* Initialize the registry by processing hooks from config
*/
async initialize(): Promise<void> {
const runtimeHooks = this.entries.filter(
(entry) => entry.source === ConfigSource.Runtime,
);
this.entries = [...runtimeHooks];
this.entries = [];
this.processHooksFromConfig();
debugLogger.debug(
@@ -122,10 +93,7 @@ export class HookRegistry {
private getHookName(
entry: HookRegistryEntry | { config: HookConfig },
): string {
if (entry.config.type === 'command') {
return entry.config.name || entry.config.command || 'unknown-command';
}
return entry.config.name || 'unknown-hook';
return entry.config.name || entry.config.command || 'unknown-command';
}
/**
@@ -293,10 +261,7 @@ please review the project settings (.gemini/settings.json) and remove them.`;
eventName: HookEventName,
source: ConfigSource,
): boolean {
if (
!config.type ||
!['command', 'plugin', 'runtime'].includes(config.type)
) {
if (!config.type || !['command', 'plugin'].includes(config.type)) {
debugLogger.warn(
`Invalid hook ${eventName} from ${source} type: ${config.type}`,
);
@@ -310,13 +275,6 @@ please review the project settings (.gemini/settings.json) and remove them.`;
return false;
}
if (config.type === 'runtime' && !config.name) {
debugLogger.warn(
`Runtime hook ${eventName} from ${source} missing name field`,
);
return false;
}
return true;
}
@@ -334,8 +292,6 @@ please review the project settings (.gemini/settings.json) and remove them.`;
*/
private getSourcePriority(source: ConfigSource): number {
switch (source) {
case ConfigSource.Runtime:
return 0; // Highest
case ConfigSource.Project:
return 1;
case ConfigSource.User:
+3 -72
View File
@@ -7,8 +7,6 @@
import { spawn } from 'node:child_process';
import type {
HookConfig,
CommandHookConfig,
RuntimeHookConfig,
HookInput,
HookOutput,
HookExecutionResult,
@@ -17,7 +15,7 @@ import type {
BeforeModelOutput,
BeforeToolInput,
} from './types.js';
import { HookEventName, ConfigSource, HookType } from './types.js';
import { HookEventName, ConfigSource } from './types.js';
import type { Config } from '../config/config.js';
import type { LLMRequest } from './hookTranslator.js';
import { debugLogger } from '../utils/debugLogger.js';
@@ -77,15 +75,6 @@ export class HookRunner {
}
try {
if (hookConfig.type === HookType.Runtime) {
return await this.executeRuntimeHook(
hookConfig,
eventName,
input,
startTime,
);
}
return await this.executeCommandHook(
hookConfig,
eventName,
@@ -94,10 +83,7 @@ export class HookRunner {
);
} catch (error) {
const duration = Date.now() - startTime;
const hookId =
hookConfig.name ||
(hookConfig.type === HookType.Command ? hookConfig.command : '') ||
'unknown';
const hookId = hookConfig.name || hookConfig.command || 'unknown';
const errorMessage = `Hook execution failed for event '${eventName}' (hook: ${hookId}): ${error}`;
debugLogger.warn(`Hook execution error (non-fatal): ${errorMessage}`);
@@ -244,66 +230,11 @@ export class HookRunner {
return modifiedInput;
}
/**
* Execute a runtime hook
*/
private async executeRuntimeHook(
hookConfig: RuntimeHookConfig,
eventName: HookEventName,
input: HookInput,
startTime: number,
): Promise<HookExecutionResult> {
const timeout = hookConfig.timeout ?? DEFAULT_HOOK_TIMEOUT;
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const controller = new AbortController();
try {
// Create a promise that rejects after timeout
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutHandle = setTimeout(
() => reject(new Error(`Hook timed out after ${timeout}ms`)),
timeout,
);
});
// Execute action with timeout race
const result = await Promise.race([
hookConfig.action(input, { signal: controller.signal }),
timeoutPromise,
]);
const output =
result === null || result === undefined ? undefined : result;
return {
hookConfig,
eventName,
success: true,
output,
duration: Date.now() - startTime,
};
} catch (error) {
// Abort the ongoing hook action if it timed out or errored
controller.abort();
return {
hookConfig,
eventName,
success: false,
error: error instanceof Error ? error : new Error(String(error)),
duration: Date.now() - startTime,
};
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
}
}
/**
* Execute a command hook
*/
private async executeCommandHook(
hookConfig: CommandHookConfig,
hookConfig: HookConfig,
eventName: HookEventName,
input: HookInput,
startTime: number,
+5 -6
View File
@@ -77,7 +77,7 @@ describe('HookSystem Integration', () => {
matcher: 'TestTool',
hooks: [
{
type: HookType.Command as const,
type: HookType.Command,
command: 'echo',
timeout: 5000,
},
@@ -164,8 +164,7 @@ describe('HookSystem Integration', () => {
{
type: 'invalid-type' as HookType, // Invalid hook type for testing
command: './test.sh',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
},
],
},
],
@@ -280,12 +279,12 @@ describe('HookSystem Integration', () => {
matcher: 'TestTool',
hooks: [
{
type: HookType.Command as const,
type: HookType.Command,
command: 'echo "enabled-hook"',
timeout: 5000,
},
{
type: HookType.Command as const,
type: HookType.Command,
command: 'echo "disabled-hook"',
timeout: 5000,
},
@@ -351,7 +350,7 @@ describe('HookSystem Integration', () => {
matcher: 'TestTool',
hooks: [
{
type: HookType.Command as const,
type: HookType.Command,
command: 'echo "will-be-disabled"',
timeout: 5000,
},
-14
View File
@@ -21,9 +21,6 @@ import type {
AfterModelHookOutput,
BeforeToolSelectionHookOutput,
McpToolContext,
HookConfig,
HookEventName,
ConfigSource,
} from './types.js';
import { NotificationType } from './types.js';
import type { AggregatedHookResult } from './hookAggregator.js';
@@ -205,17 +202,6 @@ export class HookSystem {
return this.hookRegistry.getAllHooks();
}
/**
* Register a new hook programmatically
*/
registerHook(
config: HookConfig,
eventName: HookEventName,
options?: { matcher?: string; sequential?: boolean; source?: ConfigSource },
): void {
this.hookRegistry.registerHook(config, eventName, options);
}
/**
* Fire hook events directly
*/
@@ -1,141 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { HookSystem } from './hookSystem.js';
import { Config } from '../config/config.js';
import { HookType, HookEventName, ConfigSource } from './types.js';
import * as os from 'node:os';
import * as path from 'node:path';
import * as fs from 'node:fs';
// Mock console methods
vi.stubGlobal('console', {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
});
describe('Runtime Hooks', () => {
let hookSystem: HookSystem;
let config: Config;
beforeEach(() => {
vi.resetAllMocks();
const testDir = path.join(os.tmpdir(), 'test-runtime-hooks');
fs.mkdirSync(testDir, { recursive: true });
config = new Config({
model: 'gemini-3-flash-preview',
targetDir: testDir,
sessionId: 'test-session',
debugMode: false,
cwd: testDir,
});
// Stub getMessageBus
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(config as any).getMessageBus = () => undefined;
hookSystem = new HookSystem(config);
});
it('should register a runtime hook', async () => {
await hookSystem.initialize();
const action = vi.fn().mockResolvedValue(undefined);
hookSystem.registerHook(
{
type: HookType.Runtime,
name: 'test-hook',
action,
},
HookEventName.BeforeTool,
{ matcher: 'TestTool' },
);
const hooks = hookSystem.getAllHooks();
expect(hooks).toHaveLength(1);
expect(hooks[0].config.name).toBe('test-hook');
expect(hooks[0].source).toBe(ConfigSource.Runtime);
});
it('should execute a runtime hook', async () => {
await hookSystem.initialize();
const action = vi.fn().mockImplementation(async () => ({
decision: 'allow',
systemMessage: 'Hook ran',
}));
hookSystem.registerHook(
{
type: HookType.Runtime,
name: 'test-hook',
action,
},
HookEventName.BeforeTool,
{ matcher: 'TestTool' },
);
const result = await hookSystem
.getEventHandler()
.fireBeforeToolEvent('TestTool', { foo: 'bar' });
expect(action).toHaveBeenCalled();
expect(action.mock.calls[0][0]).toMatchObject({
tool_name: 'TestTool',
tool_input: { foo: 'bar' },
hook_event_name: 'BeforeTool',
});
expect(result.finalOutput?.systemMessage).toBe('Hook ran');
});
it('should handle runtime hook errors', async () => {
await hookSystem.initialize();
const action = vi.fn().mockRejectedValue(new Error('Hook failed'));
hookSystem.registerHook(
{
type: HookType.Runtime,
name: 'fail-hook',
action,
},
HookEventName.BeforeTool,
{ matcher: 'TestTool' },
);
// Should not throw, but handle error gracefully
await hookSystem.getEventHandler().fireBeforeToolEvent('TestTool', {});
expect(action).toHaveBeenCalled();
});
it('should preserve runtime hooks across re-initialization', async () => {
await hookSystem.initialize();
hookSystem.registerHook(
{
type: HookType.Runtime,
name: 'persist-hook',
action: async () => {},
},
HookEventName.BeforeTool,
{ matcher: 'TestTool' },
);
expect(hookSystem.getAllHooks()).toHaveLength(1);
// Re-initialize
await hookSystem.initialize();
expect(hookSystem.getAllHooks()).toHaveLength(1);
expect(hookSystem.getAllHooks()[0].config.name).toBe('persist-hook');
});
});
+11 -32
View File
@@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as fs from 'node:fs';
import { TrustedHooksManager } from './trustedHooks.js';
import { Storage } from '../config/storage.js';
import { HookEventName, HookType, type HookDefinition } from './types.js';
import { HookEventName, HookType } from './types.js';
vi.mock('node:fs');
vi.mock('../config/storage.js');
@@ -72,16 +72,8 @@ describe('TrustedHooksManager', () => {
[HookEventName.BeforeTool]: [
{
hooks: [
{
name: 'trusted-hook',
type: HookType.Command,
command: 'cmd1',
} as const,
{
name: 'new-hook',
type: HookType.Command,
command: 'cmd2',
} as const,
{ name: 'trusted-hook', type: HookType.Command, command: 'cmd1' },
{ name: 'new-hook', type: HookType.Command, command: 'cmd2' },
],
},
],
@@ -98,11 +90,7 @@ describe('TrustedHooksManager', () => {
[HookEventName.BeforeTool]: [
{
hooks: [
{
name: 'trusted-hook',
type: HookType.Command,
command: 'cmd1',
} as const,
{ name: 'trusted-hook', type: HookType.Command, command: 'cmd1' },
],
},
],
@@ -126,12 +114,9 @@ describe('TrustedHooksManager', () => {
],
};
expect(
manager.getUntrustedHooks(
'/project',
projectHooks as Partial<Record<HookEventName, HookDefinition[]>>,
),
).toEqual(['./script.sh']);
expect(manager.getUntrustedHooks('/project', projectHooks)).toEqual([
'./script.sh',
]);
});
it('should detect change in command as untrusted', () => {
@@ -157,17 +142,11 @@ describe('TrustedHooksManager', () => {
],
};
manager.trustHooks(
'/project',
originalHook as Partial<Record<HookEventName, HookDefinition[]>>,
);
manager.trustHooks('/project', originalHook);
expect(
manager.getUntrustedHooks(
'/project',
updatedHook as Partial<Record<HookEventName, HookDefinition[]>>,
),
).toEqual(['my-hook']);
expect(manager.getUntrustedHooks('/project', updatedHook)).toEqual([
'my-hook',
]);
});
});
-3
View File
@@ -9,7 +9,6 @@ import * as path from 'node:path';
import { Storage } from '../config/storage.js';
import {
getHookKey,
HookType,
type HookDefinition,
type HookEventName,
} from './types.js';
@@ -80,7 +79,6 @@ export class TrustedHooksManager {
for (const def of definitions) {
if (!def || !Array.isArray(def.hooks)) continue;
for (const hook of def.hooks) {
if (hook.type === HookType.Runtime) continue;
const key = getHookKey(hook);
if (!trustedKeys.has(key)) {
// Return friendly name or command
@@ -110,7 +108,6 @@ export class TrustedHooksManager {
for (const def of definitions) {
if (!def || !Array.isArray(def.hooks)) continue;
for (const hook of def.hooks) {
if (hook.type === HookType.Runtime) continue;
currentTrusted.add(getHookKey(hook));
}
}
+10 -36
View File
@@ -21,7 +21,6 @@ import { defaultHookTranslator } from './hookTranslator.js';
* Configuration source levels in precedence order (highest to lowest)
*/
export enum ConfigSource {
Runtime = 'runtime',
Project = 'project',
User = 'user',
System = 'system',
@@ -51,43 +50,11 @@ export enum HookEventName {
export const HOOKS_CONFIG_FIELDS = ['enabled', 'disabled', 'notifications'];
/**
* Hook implementation types
*/
export enum HookType {
Command = 'command',
Runtime = 'runtime',
}
/**
* Hook action function
*/
export type HookAction = (
input: HookInput,
options?: { signal: AbortSignal },
) => Promise<HookOutput | void | null>;
/**
* Runtime hook configuration
*/
export interface RuntimeHookConfig {
type: HookType.Runtime;
/** Unique name for the runtime hook */
name: string;
/** Function to execute when the hook is triggered */
action: HookAction;
command?: never;
source?: ConfigSource;
/** Maximum time allowed for hook execution in milliseconds */
timeout?: number;
}
/**
* Command hook configuration entry
* Hook configuration entry
*/
export interface CommandHookConfig {
type: HookType.Command;
command: string;
action?: never;
name?: string;
description?: string;
timeout?: number;
@@ -95,7 +62,7 @@ export interface CommandHookConfig {
env?: Record<string, string>;
}
export type HookConfig = CommandHookConfig | RuntimeHookConfig;
export type HookConfig = CommandHookConfig;
/**
* Hook definition with matcher
@@ -106,12 +73,19 @@ export interface HookDefinition {
hooks: HookConfig[];
}
/**
* Hook implementation types
*/
export enum HookType {
Command = 'command',
}
/**
* Generate a unique key for a hook configuration
*/
export function getHookKey(hook: HookConfig): string {
const name = hook.name || '';
const command = hook.type === HookType.Command ? hook.command : '';
const command = hook.command || '';
return `${name}:${command}`;
}
-1
View File
@@ -78,7 +78,6 @@ export * from './utils/authConsent.js';
export * from './utils/googleQuotaErrors.js';
export * from './utils/fileUtils.js';
export * from './utils/planUtils.js';
export * from './utils/approvalModeUtils.js';
export * from './utils/fileDiffUtils.js';
export * from './utils/retry.js';
export * from './utils/shell-utils.js';
+1
View File
@@ -41,6 +41,7 @@ export const DEFAULT_CORE_POLICIES_DIR = path.join(__dirname, 'policies');
export const DEFAULT_POLICY_TIER = 1;
export const WORKSPACE_POLICY_TIER = 2;
export const USER_POLICY_TIER = 3;
export const EXTENSION_POLICY_TIER = WORKSPACE_POLICY_TIER;
export const ADMIN_POLICY_TIER = 4;
// Specific priority offsets and derived priorities for dynamic/settings rules.
@@ -406,6 +406,40 @@ describe('PolicyEngine', () => {
expect(remainingRules.some((r) => r.toolName === 'tool2')).toBe(true);
});
it('should remove rules for specific tool and source', () => {
engine.addRule({
toolName: 'tool1',
decision: PolicyDecision.ALLOW,
source: 'source1',
});
engine.addRule({
toolName: 'tool1',
decision: PolicyDecision.DENY,
source: 'source2',
});
engine.addRule({
toolName: 'tool2',
decision: PolicyDecision.ALLOW,
source: 'source1',
});
expect(engine.getRules()).toHaveLength(3);
engine.removeRulesForTool('tool1', 'source1');
const rules = engine.getRules();
expect(rules).toHaveLength(2);
expect(
rules.some((r) => r.toolName === 'tool1' && r.source === 'source2'),
).toBe(true);
expect(
rules.some((r) => r.toolName === 'tool2' && r.source === 'source1'),
).toBe(true);
expect(
rules.some((r) => r.toolName === 'tool1' && r.source === 'source1'),
).toBe(false);
});
it('should handle removing non-existent tool', () => {
engine.addRule({ toolName: 'existing', decision: PolicyDecision.ALLOW });
@@ -2828,6 +2862,34 @@ describe('PolicyEngine', () => {
});
});
describe('removeRulesBySource', () => {
it('should remove rules matching a specific source', () => {
engine.addRule({
toolName: 'rule1',
decision: PolicyDecision.ALLOW,
source: 'source1',
});
engine.addRule({
toolName: 'rule2',
decision: PolicyDecision.ALLOW,
source: 'source2',
});
engine.addRule({
toolName: 'rule3',
decision: PolicyDecision.ALLOW,
source: 'source1',
});
expect(engine.getRules()).toHaveLength(3);
engine.removeRulesBySource('source1');
const rules = engine.getRules();
expect(rules).toHaveLength(1);
expect(rules[0].toolName).toBe('rule2');
});
});
describe('removeCheckersByTier', () => {
it('should remove checkers matching a specific tier', () => {
engine.addChecker({
@@ -2853,6 +2915,31 @@ describe('PolicyEngine', () => {
});
});
describe('removeCheckersBySource', () => {
it('should remove checkers matching a specific source', () => {
engine.addChecker({
checker: { type: 'external', name: 'c1' },
source: 'sourceA',
});
engine.addChecker({
checker: { type: 'external', name: 'c2' },
source: 'sourceB',
});
engine.addChecker({
checker: { type: 'external', name: 'c3' },
source: 'sourceA',
});
expect(engine.getCheckers()).toHaveLength(3);
engine.removeCheckersBySource('sourceA');
const checkers = engine.getCheckers();
expect(checkers).toHaveLength(1);
expect(checkers[0].checker.name).toBe('c2');
});
});
describe('Tool Annotations', () => {
it('should match tools by semantic annotations', async () => {
engine = new PolicyEngine({
@@ -2916,4 +3003,22 @@ describe('PolicyEngine', () => {
).toBe(PolicyDecision.ALLOW);
});
});
describe('hook checkers', () => {
it('should add and retrieve hook checkers in priority order', () => {
engine.addHookChecker({
checker: { type: 'external', name: 'h1' },
priority: 5,
});
engine.addHookChecker({
checker: { type: 'external', name: 'h2' },
priority: 10,
});
const hookCheckers = engine.getHookCheckers();
expect(hookCheckers).toHaveLength(2);
expect(hookCheckers[0].priority).toBe(10);
expect(hookCheckers[1].priority).toBe(5);
});
});
});
+16
View File
@@ -562,6 +562,13 @@ export class PolicyEngine {
);
}
/**
* Remove rules matching a specific source.
*/
removeRulesBySource(source: string): void {
this.rules = this.rules.filter((rule) => rule.source !== source);
}
/**
* Remove checkers matching a specific tier (priority band).
*/
@@ -571,6 +578,15 @@ export class PolicyEngine {
);
}
/**
* Remove checkers matching a specific source.
*/
removeCheckersBySource(source: string): void {
this.checkers = this.checkers.filter(
(checker) => checker.source !== source,
);
}
/**
* Remove rules for a specific tool.
* If source is provided, only rules matching that source are removed.
+14 -14
View File
@@ -262,30 +262,30 @@ deny_message = "Deletion is permanent"
expect(result.errors).toHaveLength(0);
});
it('should support modes property for Tier 2 and Tier 3 policies', async () => {
it('should support modes property for Tier 3 and Tier 4 policies', async () => {
await fs.writeFile(
path.join(tempDir, 'tier2.toml'),
path.join(tempDir, 'tier3.toml'),
`
[[rule]]
toolName = "tier2-tool"
toolName = "tier3-tool"
decision = "allow"
priority = 100
modes = ["autoEdit"]
`,
);
const getPolicyTier2 = (_dir: string) => 2; // Tier 2
const result2 = await loadPoliciesFromToml([tempDir], getPolicyTier2);
expect(result2.rules).toHaveLength(1);
expect(result2.rules[0].toolName).toBe('tier2-tool');
expect(result2.rules[0].modes).toEqual(['autoEdit']);
expect(result2.rules[0].source).toBe('Workspace: tier2.toml');
const getPolicyTier3 = (_dir: string) => 3; // Tier 3
const getPolicyTier3 = (_dir: string) => 3; // Tier 3 (User)
const result3 = await loadPoliciesFromToml([tempDir], getPolicyTier3);
expect(result3.rules[0].source).toBe('User: tier2.toml');
expect(result3.errors).toHaveLength(0);
expect(result3.rules).toHaveLength(1);
expect(result3.rules[0].toolName).toBe('tier3-tool');
expect(result3.rules[0].modes).toEqual(['autoEdit']);
expect(result3.rules[0].source).toBe('User: tier3.toml');
const getPolicyTier4 = (_dir: string) => 4; // Tier 4 (Admin)
const result4 = await loadPoliciesFromToml([tempDir], getPolicyTier4);
expect(result4.rules[0].source).toBe('Admin: tier3.toml');
expect(result4.errors).toHaveLength(0);
});
it('should handle TOML parse errors', async () => {
@@ -14,12 +14,10 @@ import { DefaultStrategy } from './strategies/defaultStrategy.js';
import { CompositeStrategy } from './strategies/compositeStrategy.js';
import { FallbackStrategy } from './strategies/fallbackStrategy.js';
import { OverrideStrategy } from './strategies/overrideStrategy.js';
import { ApprovalModeStrategy } from './strategies/approvalModeStrategy.js';
import { ClassifierStrategy } from './strategies/classifierStrategy.js';
import { NumericalClassifierStrategy } from './strategies/numericalClassifierStrategy.js';
import { logModelRouting } from '../telemetry/loggers.js';
import { ModelRoutingEvent } from '../telemetry/types.js';
import { ApprovalMode } from '../policy/types.js';
vi.mock('../config/config.js');
vi.mock('../core/baseLlmClient.js');
@@ -27,7 +25,6 @@ vi.mock('./strategies/defaultStrategy.js');
vi.mock('./strategies/compositeStrategy.js');
vi.mock('./strategies/fallbackStrategy.js');
vi.mock('./strategies/overrideStrategy.js');
vi.mock('./strategies/approvalModeStrategy.js');
vi.mock('./strategies/classifierStrategy.js');
vi.mock('./strategies/numericalClassifierStrategy.js');
vi.mock('../telemetry/loggers.js');
@@ -48,15 +45,11 @@ describe('ModelRouterService', () => {
vi.spyOn(mockConfig, 'getBaseLlmClient').mockReturnValue(mockBaseLlmClient);
vi.spyOn(mockConfig, 'getNumericalRoutingEnabled').mockResolvedValue(false);
vi.spyOn(mockConfig, 'getClassifierThreshold').mockResolvedValue(undefined);
vi.spyOn(mockConfig, 'getApprovalMode').mockReturnValue(
ApprovalMode.DEFAULT,
);
mockCompositeStrategy = new CompositeStrategy(
[
new FallbackStrategy(),
new OverrideStrategy(),
new ApprovalModeStrategy(),
new ClassifierStrategy(),
new NumericalClassifierStrategy(),
new DefaultStrategy(),
@@ -86,13 +79,12 @@ describe('ModelRouterService', () => {
const compositeStrategyArgs = vi.mocked(CompositeStrategy).mock.calls[0];
const childStrategies = compositeStrategyArgs[0];
expect(childStrategies.length).toBe(6);
expect(childStrategies.length).toBe(5);
expect(childStrategies[0]).toBeInstanceOf(FallbackStrategy);
expect(childStrategies[1]).toBeInstanceOf(OverrideStrategy);
expect(childStrategies[2]).toBeInstanceOf(ApprovalModeStrategy);
expect(childStrategies[3]).toBeInstanceOf(ClassifierStrategy);
expect(childStrategies[4]).toBeInstanceOf(NumericalClassifierStrategy);
expect(childStrategies[5]).toBeInstanceOf(DefaultStrategy);
expect(childStrategies[2]).toBeInstanceOf(ClassifierStrategy);
expect(childStrategies[3]).toBeInstanceOf(NumericalClassifierStrategy);
expect(childStrategies[4]).toBeInstanceOf(DefaultStrategy);
expect(compositeStrategyArgs[1]).toBe('agent-router');
});
@@ -135,7 +127,6 @@ describe('ModelRouterService', () => {
'Strategy reasoning',
false,
undefined,
ApprovalMode.DEFAULT,
false,
undefined,
);
@@ -162,7 +153,6 @@ describe('ModelRouterService', () => {
'An exception occurred during routing.',
true,
'Strategy failed',
ApprovalMode.DEFAULT,
false,
undefined,
);
@@ -16,7 +16,6 @@ import { NumericalClassifierStrategy } from './strategies/numericalClassifierStr
import { CompositeStrategy } from './strategies/compositeStrategy.js';
import { FallbackStrategy } from './strategies/fallbackStrategy.js';
import { OverrideStrategy } from './strategies/overrideStrategy.js';
import { ApprovalModeStrategy } from './strategies/approvalModeStrategy.js';
import { logModelRouting } from '../telemetry/loggers.js';
import { ModelRoutingEvent } from '../telemetry/types.js';
@@ -41,7 +40,6 @@ export class ModelRouterService {
[
new FallbackStrategy(),
new OverrideStrategy(),
new ApprovalModeStrategy(),
new ClassifierStrategy(),
new NumericalClassifierStrategy(),
new DefaultStrategy(),
@@ -107,7 +105,6 @@ export class ModelRouterService {
decision!.metadata.reasoning,
failed,
error_message,
this.config.getApprovalMode(),
enableNumericalRouting,
classifierThreshold,
);
@@ -1,187 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ApprovalModeStrategy } from './approvalModeStrategy.js';
import type { RoutingContext } from '../routingStrategy.js';
import type { Config } from '../../config/config.js';
import {
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
DEFAULT_GEMINI_MODEL_AUTO,
PREVIEW_GEMINI_MODEL_AUTO,
} from '../../config/models.js';
import { ApprovalMode } from '../../policy/types.js';
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
describe('ApprovalModeStrategy', () => {
let strategy: ApprovalModeStrategy;
let mockContext: RoutingContext;
let mockConfig: Config;
let mockBaseLlmClient: BaseLlmClient;
beforeEach(() => {
vi.clearAllMocks();
strategy = new ApprovalModeStrategy();
mockContext = {
history: [],
request: [{ text: 'test' }],
signal: new AbortController().signal,
};
mockConfig = {
getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO),
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
getApprovedPlanPath: vi.fn().mockReturnValue(undefined),
getPlanModeRoutingEnabled: vi.fn().mockResolvedValue(true),
} as unknown as Config;
mockBaseLlmClient = {} as BaseLlmClient;
});
it('should return null if the model is not an auto model', async () => {
vi.mocked(mockConfig.getModel).mockReturnValue(DEFAULT_GEMINI_MODEL);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(decision).toBeNull();
});
it('should return null if plan mode routing is disabled', async () => {
vi.mocked(mockConfig.getPlanModeRoutingEnabled).mockResolvedValue(false);
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(decision).toBeNull();
});
it('should route to PRO model if ApprovalMode is PLAN (Gemini 2.5)', async () => {
vi.mocked(mockConfig.getModel).mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(decision).toEqual({
model: DEFAULT_GEMINI_MODEL,
metadata: {
source: 'approval-mode',
latencyMs: expect.any(Number),
reasoning: 'Routing to Pro model because ApprovalMode is PLAN.',
},
});
});
it('should route to PRO model if ApprovalMode is PLAN (Gemini 3)', async () => {
vi.mocked(mockConfig.getModel).mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO);
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(decision).toEqual({
model: PREVIEW_GEMINI_MODEL,
metadata: {
source: 'approval-mode',
latencyMs: expect.any(Number),
reasoning: 'Routing to Pro model because ApprovalMode is PLAN.',
},
});
});
it('should route to FLASH model if an approved plan exists (Gemini 2.5)', async () => {
vi.mocked(mockConfig.getModel).mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.DEFAULT);
vi.mocked(mockConfig.getApprovedPlanPath).mockReturnValue(
'/path/to/plan.md',
);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(decision).toEqual({
model: DEFAULT_GEMINI_FLASH_MODEL,
metadata: {
source: 'approval-mode',
latencyMs: expect.any(Number),
reasoning:
'Routing to Flash model because an approved plan exists at /path/to/plan.md.',
},
});
});
it('should route to FLASH model if an approved plan exists (Gemini 3)', async () => {
vi.mocked(mockConfig.getModel).mockReturnValue(PREVIEW_GEMINI_MODEL_AUTO);
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.DEFAULT);
vi.mocked(mockConfig.getApprovedPlanPath).mockReturnValue(
'/path/to/plan.md',
);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(decision).toEqual({
model: PREVIEW_GEMINI_FLASH_MODEL,
metadata: {
source: 'approval-mode',
latencyMs: expect.any(Number),
reasoning:
'Routing to Flash model because an approved plan exists at /path/to/plan.md.',
},
});
});
it('should return null if not in PLAN mode and no approved plan exists', async () => {
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.DEFAULT);
vi.mocked(mockConfig.getApprovedPlanPath).mockReturnValue(undefined);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(decision).toBeNull();
});
it('should prioritize requestedModel over config model if it is an auto model', async () => {
mockContext.requestedModel = PREVIEW_GEMINI_MODEL_AUTO;
vi.mocked(mockConfig.getModel).mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO);
vi.mocked(mockConfig.getApprovalMode).mockReturnValue(ApprovalMode.PLAN);
const decision = await strategy.route(
mockContext,
mockConfig,
mockBaseLlmClient,
);
expect(decision?.model).toBe(PREVIEW_GEMINI_MODEL);
});
});
@@ -1,83 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config } from '../../config/config.js';
import {
DEFAULT_GEMINI_MODEL,
DEFAULT_GEMINI_FLASH_MODEL,
PREVIEW_GEMINI_MODEL,
PREVIEW_GEMINI_FLASH_MODEL,
isAutoModel,
isPreviewModel,
} from '../../config/models.js';
import type { BaseLlmClient } from '../../core/baseLlmClient.js';
import { ApprovalMode } from '../../policy/types.js';
import type {
RoutingContext,
RoutingDecision,
RoutingStrategy,
} from '../routingStrategy.js';
/**
* A strategy that routes based on the current ApprovalMode and plan status.
*
* - In PLAN mode: Routes to the PRO model for high-quality planning.
* - In other modes with an approved plan: Routes to the FLASH model for efficient implementation.
*/
export class ApprovalModeStrategy implements RoutingStrategy {
readonly name = 'approval-mode';
async route(
context: RoutingContext,
config: Config,
_baseLlmClient: BaseLlmClient,
): Promise<RoutingDecision | null> {
const model = context.requestedModel ?? config.getModel();
// This strategy only applies to "auto" models.
if (!isAutoModel(model)) {
return null;
}
if (!(await config.getPlanModeRoutingEnabled())) {
return null;
}
const startTime = Date.now();
const approvalMode = config.getApprovalMode();
const approvedPlanPath = config.getApprovedPlanPath();
const isPreview = isPreviewModel(model);
// 1. Planning Phase: If ApprovalMode === PLAN, explicitly route to the Pro model.
if (approvalMode === ApprovalMode.PLAN) {
const proModel = isPreview ? PREVIEW_GEMINI_MODEL : DEFAULT_GEMINI_MODEL;
return {
model: proModel,
metadata: {
source: this.name,
latencyMs: Date.now() - startTime,
reasoning: 'Routing to Pro model because ApprovalMode is PLAN.',
},
};
} else if (approvedPlanPath) {
// 2. Implementation Phase: If ApprovalMode !== PLAN AND an approved plan path is set, prefer the Flash model.
const flashModel = isPreview
? PREVIEW_GEMINI_FLASH_MODEL
: DEFAULT_GEMINI_FLASH_MODEL;
return {
model: flashModel,
metadata: {
source: this.name,
latencyMs: Date.now() - startTime,
reasoning: `Routing to Flash model because an approved plan exists at ${approvedPlanPath}.`,
},
};
}
return null;
}
}
@@ -35,9 +35,7 @@ import {
WebFetchFallbackAttemptEvent,
HookCallEvent,
} from '../types.js';
import { HookType } from '../../hooks/types.js';
import { AgentTerminateMode } from '../../agents/types.js';
import { ApprovalMode } from '../../policy/types.js';
import { GIT_COMMIT_INFO, CLI_VERSION } from '../../generated/git-commit.js';
import { UserAccountManager } from '../../utils/userAccountManager.js';
import { InstallationManager } from '../../utils/installationManager.js';
@@ -906,7 +904,6 @@ describe('ClearcutLogger', () => {
'some reasoning',
false,
undefined,
ApprovalMode.DEFAULT,
);
logger?.logModelRoutingEvent(event);
@@ -941,7 +938,6 @@ describe('ClearcutLogger', () => {
'some reasoning',
true,
'Something went wrong',
ApprovalMode.DEFAULT,
);
logger?.logModelRoutingEvent(event);
@@ -980,7 +976,6 @@ describe('ClearcutLogger', () => {
'[Score: 90 / Threshold: 80] reasoning',
false,
undefined,
ApprovalMode.DEFAULT,
true,
'80',
);
@@ -1406,7 +1401,7 @@ describe('ClearcutLogger', () => {
const event = new HookCallEvent(
'before-tool',
HookType.Command,
'command',
hookName,
{}, // input
150, // duration
@@ -846,40 +846,6 @@ export class ClearcutLogger {
EventMetadataKey.GEMINI_CLI_API_RESPONSE_TOOL_TOKEN_COUNT,
value: JSON.stringify(event.usage.tool_token_count),
},
// Context breakdown fields are only populated on turn-ending responses
// (when the user gets back control), not during intermediate tool-use
// loops. Values still grow across turns as conversation history
// accumulates, so downstream consumers should use the last event per
// session (MAX) rather than summing across events.
{
gemini_cli_key:
EventMetadataKey.GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_SYSTEM_INSTRUCTIONS,
value: JSON.stringify(
event.usage.context_breakdown?.system_instructions ?? 0,
),
},
{
gemini_cli_key:
EventMetadataKey.GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_TOOL_DEFINITIONS,
value: JSON.stringify(
event.usage.context_breakdown?.tool_definitions ?? 0,
),
},
{
gemini_cli_key:
EventMetadataKey.GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_HISTORY,
value: JSON.stringify(event.usage.context_breakdown?.history ?? 0),
},
{
gemini_cli_key:
EventMetadataKey.GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_TOOL_CALLS,
value: JSON.stringify(event.usage.context_breakdown?.tool_calls ?? {}),
},
{
gemini_cli_key:
EventMetadataKey.GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_MCP_SERVERS,
value: JSON.stringify(event.usage.context_breakdown?.mcp_servers ?? 0),
},
];
this.enqueueLogEvent(this.createLogEvent(EventNames.API_RESPONSE, data));
@@ -7,7 +7,7 @@
// Defines valid event metadata keys for Clearcut logging.
export enum EventMetadataKey {
// Deleted enums: 24
// Next ID: 172
// Next ID: 167
GEMINI_CLI_KEY_UNKNOWN = 0,
@@ -137,21 +137,6 @@ export enum EventMetadataKey {
// Logs the tool use token count of the API call.
GEMINI_CLI_API_RESPONSE_TOOL_TOKEN_COUNT = 29,
// Logs the token count for system instructions.
GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_SYSTEM_INSTRUCTIONS = 167,
// Logs the token count for tool definitions.
GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_TOOL_DEFINITIONS = 168,
// Logs the token count for conversation history.
GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_HISTORY = 169,
// Logs the token count for tool calls (JSON map of tool name to tokens).
GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_TOOL_CALLS = 170,
// Logs the token count from MCP servers (tool definitions + tool inputs/outputs).
GEMINI_CLI_API_RESPONSE_CONTEXT_BREAKDOWN_MCP_SERVERS = 171,
// ==========================================================================
// GenAI API Error Event Keys
// ===========================================================================
+1 -6
View File
@@ -24,7 +24,6 @@ import {
import { OutputFormat } from '../output/types.js';
import { logs } from '@opentelemetry/api-logs';
import type { Config, GeminiCLIExtension } from '../config/config.js';
import { ApprovalMode } from '../policy/types.js';
import {
logApiError,
logApiRequest,
@@ -96,7 +95,6 @@ import {
EVENT_HOOK_CALL,
LlmRole,
} from './types.js';
import { HookType } from '../hooks/types.js';
import * as metrics from './metrics.js';
import { FileOperation } from './metrics.js';
import * as sdk from './sdk.js';
@@ -1857,7 +1855,6 @@ describe('loggers', () => {
'test-reason',
false,
undefined,
ApprovalMode.DEFAULT,
);
logModelRouting(mockConfig, event);
@@ -1892,7 +1889,6 @@ describe('loggers', () => {
'[Score: 90 / Threshold: 80] reasoning',
false,
undefined,
ApprovalMode.DEFAULT,
true,
'80',
);
@@ -1926,7 +1922,6 @@ describe('loggers', () => {
'test-reason',
false,
undefined,
ApprovalMode.DEFAULT,
);
logModelRouting(mockConfig, event);
@@ -2332,7 +2327,7 @@ describe('loggers', () => {
it('should log hook call event to Clearcut and OTEL', () => {
const event = new HookCallEvent(
'before-tool',
HookType.Command,
'command',
'/path/to/script.sh',
{ arg: 'val' },
150,
@@ -27,7 +27,6 @@ import {
TokenStorageInitializationEvent,
} from './types.js';
import { AgentTerminateMode } from '../agents/types.js';
import { ApprovalMode } from '../policy/types.js';
const mockCounterAddFn: Mock<
(value: number, attributes?: Attributes, context?: Context) => void
@@ -491,7 +490,6 @@ describe('Telemetry Metrics', () => {
'test-reason',
false,
undefined,
ApprovalMode.DEFAULT,
);
recordModelRoutingMetricsModule(mockConfig, event);
expect(mockHistogramRecordFn).not.toHaveBeenCalled();
@@ -507,7 +505,6 @@ describe('Telemetry Metrics', () => {
'test-reason',
false,
undefined,
ApprovalMode.DEFAULT,
);
recordModelRoutingMetricsModule(mockConfig, event);
@@ -519,7 +516,6 @@ describe('Telemetry Metrics', () => {
'routing.decision_source': 'default',
'routing.failed': false,
'routing.reasoning': 'test-reason',
'routing.approval_mode': ApprovalMode.DEFAULT,
});
// The session counter is called once on init
expect(mockCounterAddFn).toHaveBeenCalledTimes(1);
@@ -534,7 +530,6 @@ describe('Telemetry Metrics', () => {
'test-reason',
true,
'test-error',
ApprovalMode.DEFAULT,
);
recordModelRoutingMetricsModule(mockConfig, event);
@@ -546,7 +541,6 @@ describe('Telemetry Metrics', () => {
'routing.decision_source': 'Classifier',
'routing.failed': true,
'routing.reasoning': 'test-reason',
'routing.approval_mode': ApprovalMode.DEFAULT,
});
expect(mockCounterAddFn).toHaveBeenCalledTimes(2);
@@ -558,7 +552,6 @@ describe('Telemetry Metrics', () => {
'routing.decision_source': 'Classifier',
'routing.failed': true,
'routing.reasoning': 'test-reason',
'routing.approval_mode': ApprovalMode.DEFAULT,
'routing.error_message': 'test-error',
});
});
-1
View File
@@ -863,7 +863,6 @@ export function recordModelRoutingMetrics(
'routing.decision_model': event.decision_model,
'routing.decision_source': event.decision_source,
'routing.failed': event.failed,
'routing.approval_mode': event.approval_mode,
};
if (event.reasoning) {
+18 -19
View File
@@ -14,7 +14,6 @@
import { describe, it, expect } from 'vitest';
import { HookCallEvent, EVENT_HOOK_CALL } from './types.js';
import { HookType } from '../hooks/types.js';
import type { Config } from '../config/config.js';
/**
@@ -41,7 +40,7 @@ describe('Telemetry Sanitization', () => {
it('should create an event with all fields', () => {
const event = new HookCallEvent(
'BeforeTool',
HookType.Command,
'command',
'test-hook',
{ tool_name: 'ReadFile' },
100,
@@ -70,7 +69,7 @@ describe('Telemetry Sanitization', () => {
it('should create an event with minimal fields', () => {
const event = new HookCallEvent(
'BeforeTool',
HookType.Command,
'command',
'test-hook',
{ tool_name: 'ReadFile' },
100,
@@ -91,7 +90,7 @@ describe('Telemetry Sanitization', () => {
it('should include all fields when logPrompts is enabled', () => {
const event = new HookCallEvent(
'BeforeTool',
HookType.Command,
'command',
'/path/to/.gemini/hooks/check-secrets.sh --api-key=abc123',
{ tool_name: 'ReadFile', args: { file: 'secret.txt' } },
100,
@@ -124,7 +123,7 @@ describe('Telemetry Sanitization', () => {
it('should include hook_input and hook_output as JSON strings', () => {
const event = new HookCallEvent(
'BeforeTool',
HookType.Command,
'command',
'test-hook',
{ tool_name: 'ReadFile', args: { file: 'test.txt' } },
100,
@@ -155,7 +154,7 @@ describe('Telemetry Sanitization', () => {
it('should exclude PII-sensitive fields when logPrompts is disabled', () => {
const event = new HookCallEvent(
'BeforeTool',
HookType.Command,
'command',
'/path/to/.gemini/hooks/check-secrets.sh --api-key=abc123',
{ tool_name: 'ReadFile', args: { file: 'secret.txt' } },
100,
@@ -233,7 +232,7 @@ describe('Telemetry Sanitization', () => {
for (const testCase of testCases) {
const event = new HookCallEvent(
'BeforeTool',
HookType.Command,
'command',
testCase.input,
{ tool_name: 'ReadFile' },
100,
@@ -249,7 +248,7 @@ describe('Telemetry Sanitization', () => {
it('should still include error field even when logPrompts is disabled', () => {
const event = new HookCallEvent(
'BeforeTool',
HookType.Command,
'command',
'test-hook',
{ tool_name: 'ReadFile' },
100,
@@ -277,7 +276,7 @@ describe('Telemetry Sanitization', () => {
it('should handle commands with multiple spaces', () => {
const event = new HookCallEvent(
'BeforeTool',
HookType.Command,
'command',
'python script.py --arg1 --arg2',
{},
100,
@@ -291,7 +290,7 @@ describe('Telemetry Sanitization', () => {
it('should handle mixed path separators', () => {
const event = new HookCallEvent(
'BeforeTool',
HookType.Command,
'command',
'/path/to\\mixed\\separators.sh',
{},
100,
@@ -305,7 +304,7 @@ describe('Telemetry Sanitization', () => {
it('should handle trailing slashes', () => {
const event = new HookCallEvent(
'BeforeTool',
HookType.Command,
'command',
'/path/to/directory/',
{},
100,
@@ -321,7 +320,7 @@ describe('Telemetry Sanitization', () => {
it('should format success message correctly', () => {
const event = new HookCallEvent(
'BeforeTool',
HookType.Command,
'command',
'test-hook',
{},
150,
@@ -336,7 +335,7 @@ describe('Telemetry Sanitization', () => {
it('should format failure message correctly', () => {
const event = new HookCallEvent(
'AfterTool',
HookType.Command,
'command',
'validation-hook',
{},
75,
@@ -355,7 +354,7 @@ describe('Telemetry Sanitization', () => {
const event = new HookCallEvent(
'BeforeModel',
HookType.Command,
'command',
'$GEMINI_PROJECT_DIR/.gemini/hooks/add-context.sh',
{
llm_request: {
@@ -395,7 +394,7 @@ describe('Telemetry Sanitization', () => {
const event = new HookCallEvent(
'BeforeModel',
HookType.Command,
'command',
'$GEMINI_PROJECT_DIR/.gemini/hooks/add-context.sh',
{
llm_request: {
@@ -439,7 +438,7 @@ describe('Telemetry Sanitization', () => {
it('should sanitize commands with API keys', () => {
const event = new HookCallEvent(
'BeforeTool',
HookType.Command,
'command',
'curl https://api.example.com -H "Authorization: Bearer sk-abc123xyz"',
{},
100,
@@ -453,7 +452,7 @@ describe('Telemetry Sanitization', () => {
it('should sanitize commands with database credentials', () => {
const event = new HookCallEvent(
'BeforeTool',
HookType.Command,
'command',
'psql postgresql://user:password@localhost/db',
{},
100,
@@ -467,7 +466,7 @@ describe('Telemetry Sanitization', () => {
it('should sanitize commands with environment variables containing secrets', () => {
const event = new HookCallEvent(
'BeforeTool',
HookType.Command,
'command',
'AWS_SECRET_KEY=abc123 aws s3 ls',
{},
100,
@@ -481,7 +480,7 @@ describe('Telemetry Sanitization', () => {
it('should sanitize Python scripts with file paths', () => {
const event = new HookCallEvent(
'BeforeTool',
HookType.Command,
'command',
'python /home/john.doe/projects/secret-scanner/scan.py --config=/etc/secrets.yml',
{},
100,
+2 -16
View File
@@ -43,7 +43,6 @@ import { sanitizeHookName } from './sanitize.js';
import { getFileDiffFromResultDisplay } from '../utils/fileDiffUtils.js';
import { LlmRole } from './llmRole.js';
export { LlmRole };
import type { HookType } from '../hooks/types.js';
export interface BaseTelemetryEvent {
'event.name': string;
@@ -569,14 +568,6 @@ export interface GenAIResponseDetails {
candidates?: Candidate[];
}
export interface ContextBreakdown {
system_instructions: number;
tool_definitions: number;
history: number;
tool_calls: Record<string, number>;
mcp_servers: number;
}
export interface GenAIUsageDetails {
input_token_count: number;
output_token_count: number;
@@ -584,7 +575,6 @@ export interface GenAIUsageDetails {
thoughts_token_count: number;
tool_token_count: number;
total_token_count: number;
context_breakdown?: ContextBreakdown;
}
export const EVENT_API_RESPONSE = 'gemini_cli.api_response';
@@ -1370,7 +1360,6 @@ export class ModelRoutingEvent implements BaseTelemetryEvent {
error_message?: string;
enable_numerical_routing?: boolean;
classifier_threshold?: string;
approval_mode: ApprovalMode;
constructor(
decision_model: string,
@@ -1379,7 +1368,6 @@ export class ModelRoutingEvent implements BaseTelemetryEvent {
reasoning: string | undefined,
failed: boolean,
error_message: string | undefined,
approval_mode: ApprovalMode,
enable_numerical_routing?: boolean,
classifier_threshold?: string,
) {
@@ -1391,7 +1379,6 @@ export class ModelRoutingEvent implements BaseTelemetryEvent {
this.reasoning = reasoning;
this.failed = failed;
this.error_message = error_message;
this.approval_mode = approval_mode;
this.enable_numerical_routing = enable_numerical_routing;
this.classifier_threshold = classifier_threshold;
}
@@ -1405,7 +1392,6 @@ export class ModelRoutingEvent implements BaseTelemetryEvent {
decision_source: this.decision_source,
routing_latency_ms: this.routing_latency_ms,
failed: this.failed,
approval_mode: this.approval_mode,
};
if (this.reasoning) {
@@ -2180,7 +2166,7 @@ export class HookCallEvent implements BaseTelemetryEvent {
'event.name': string;
'event.timestamp': string;
hook_event_name: string;
hook_type: HookType;
hook_type: 'command';
hook_name: string;
hook_input: Record<string, unknown>;
hook_output?: Record<string, unknown>;
@@ -2193,7 +2179,7 @@ export class HookCallEvent implements BaseTelemetryEvent {
constructor(
hookEventName: string,
hookType: HookType,
hookType: 'command',
hookName: string,
hookInput: Record<string, unknown>,
durationMs: number,
+21 -8
View File
@@ -20,12 +20,30 @@ import type { Config } from '../config/config.js';
import { EXIT_PLAN_MODE_TOOL_NAME } from './tool-names.js';
import { validatePlanPath, validatePlanContent } from '../utils/planUtils.js';
import { ApprovalMode } from '../policy/types.js';
import { checkExhaustive } from '../utils/checks.js';
import { resolveToRealPath, isSubpath } from '../utils/paths.js';
import { logPlanExecution } from '../telemetry/loggers.js';
import { PlanExecutionEvent } from '../telemetry/types.js';
import { getExitPlanModeDefinition } from './definitions/coreTools.js';
import { resolveToolDeclaration } from './definitions/resolver.js';
import { getPlanModeExitMessage } from '../utils/approvalModeUtils.js';
/**
* Returns a human-readable description for an approval mode.
*/
function getApprovalModeDescription(mode: ApprovalMode): string {
switch (mode) {
case ApprovalMode.AUTO_EDIT:
return 'Auto-Edit mode (edits will be applied automatically)';
case ApprovalMode.DEFAULT:
return 'Default mode (edits will require confirmation)';
case ApprovalMode.YOLO:
case ApprovalMode.PLAN:
// YOLO and PLAN are not valid modes to enter when exiting plan mode
throw new Error(`Unexpected approval mode: ${mode}`);
default:
checkExhaustive(mode);
}
}
export interface ExitPlanModeParams {
plan_path: string;
@@ -204,20 +222,15 @@ export class ExitPlanModeInvocation extends BaseToolInvocation<
const payload = this.approvalPayload;
if (payload?.approved) {
const newMode = payload.approvalMode ?? ApprovalMode.DEFAULT;
if (newMode === ApprovalMode.PLAN || newMode === ApprovalMode.YOLO) {
throw new Error(`Unexpected approval mode: ${newMode}`);
}
this.config.setApprovalMode(newMode);
this.config.setApprovedPlanPath(resolvedPlanPath);
logPlanExecution(this.config, new PlanExecutionEvent(newMode));
const exitMessage = getPlanModeExitMessage(newMode);
const description = getApprovalModeDescription(newMode);
return {
llmContent: `${exitMessage}
llmContent: `Plan approved. Switching to ${description}.
The approved implementation plan is stored at: ${resolvedPlanPath}
Read and follow the plan strictly during implementation.`,
-10
View File
@@ -29,16 +29,6 @@ import type { MessageBus } from '../confirmation-bus/message-bus.js';
*/
export const MCP_QUALIFIED_NAME_SEPARATOR = '__';
/**
* Returns true if `name` matches the MCP qualified name format: "server__tool",
* i.e. exactly two non-empty parts separated by the MCP_QUALIFIED_NAME_SEPARATOR.
*/
export function isMcpToolName(name: string): boolean {
if (!name.includes(MCP_QUALIFIED_NAME_SEPARATOR)) return false;
const parts = name.split(MCP_QUALIFIED_NAME_SEPARATOR);
return parts.length === 2 && parts[0].length > 0 && parts[1].length > 0;
}
type ToolParams = Record<string, unknown>;
// Discriminated union for MCP Content Blocks to ensure type safety.
@@ -1,60 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { ApprovalMode } from '../policy/types.js';
import {
getApprovalModeDescription,
getPlanModeExitMessage,
} from './approvalModeUtils.js';
describe('approvalModeUtils', () => {
describe('getApprovalModeDescription', () => {
it('should return correct description for DEFAULT mode', () => {
expect(getApprovalModeDescription(ApprovalMode.DEFAULT)).toBe(
'Default mode (edits will require confirmation)',
);
});
it('should return correct description for AUTO_EDIT mode', () => {
expect(getApprovalModeDescription(ApprovalMode.AUTO_EDIT)).toBe(
'Auto-Edit mode (edits will be applied automatically)',
);
});
it('should return correct description for PLAN mode', () => {
expect(getApprovalModeDescription(ApprovalMode.PLAN)).toBe(
'Plan mode (read-only planning)',
);
});
it('should return correct description for YOLO mode', () => {
expect(getApprovalModeDescription(ApprovalMode.YOLO)).toBe(
'YOLO mode (all tool calls auto-approved)',
);
});
});
describe('getPlanModeExitMessage', () => {
it('should return standard message when not manual', () => {
expect(getPlanModeExitMessage(ApprovalMode.DEFAULT, false)).toBe(
'Plan approved. Switching to Default mode (edits will require confirmation).',
);
});
it('should return manual message when manual is true', () => {
expect(getPlanModeExitMessage(ApprovalMode.AUTO_EDIT, true)).toBe(
'User has manually exited Plan Mode. Switching to Auto-Edit mode (edits will be applied automatically).',
);
});
it('should default to non-manual message', () => {
expect(getPlanModeExitMessage(ApprovalMode.YOLO)).toBe(
'Plan approved. Switching to YOLO mode (all tool calls auto-approved).',
);
});
});
});
@@ -1,40 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { ApprovalMode } from '../policy/types.js';
import { checkExhaustive } from './checks.js';
/**
* Returns a human-readable description for an approval mode.
*/
export function getApprovalModeDescription(mode: ApprovalMode): string {
switch (mode) {
case ApprovalMode.AUTO_EDIT:
return 'Auto-Edit mode (edits will be applied automatically)';
case ApprovalMode.DEFAULT:
return 'Default mode (edits will require confirmation)';
case ApprovalMode.PLAN:
return 'Plan mode (read-only planning)';
case ApprovalMode.YOLO:
return 'YOLO mode (all tool calls auto-approved)';
default:
return checkExhaustive(mode);
}
}
/**
* Generates a consistent message for plan mode transitions.
*/
export function getPlanModeExitMessage(
newMode: ApprovalMode,
isManual: boolean = false,
): string {
const description = getApprovalModeDescription(newMode);
const prefix = isManual
? 'User has manually exited Plan Mode.'
: 'Plan approved.';
return `${prefix} Switching to ${description}.`;
}
@@ -14,6 +14,7 @@ import {
type MockInstance,
} from 'vitest';
import { SimpleExtensionLoader } from './extensionLoader.js';
import { PolicyDecision } from '../policy/types.js';
import type { Config, GeminiCLIExtension } from '../config/config.js';
import { type McpClientManager } from '../tools/mcp-client-manager.js';
import type { GeminiClient } from '../core/client.js';
@@ -38,6 +39,12 @@ describe('SimpleExtensionLoader', () => {
let mockHookSystemInit: MockInstance;
let mockAgentRegistryReload: MockInstance;
let mockSkillsReload: MockInstance;
let mockPolicyEngine: {
addRule: MockInstance;
addChecker: MockInstance;
removeRulesBySource: MockInstance;
removeCheckersBySource: MockInstance;
};
const activeExtension: GeminiCLIExtension = {
name: 'test-extension',
@@ -47,7 +54,22 @@ describe('SimpleExtensionLoader', () => {
contextFiles: [],
excludeTools: ['some-tool'],
id: '123',
rules: [
{
toolName: 'test-tool',
decision: PolicyDecision.ALLOW,
source: 'Extension (test-extension): Extension: policies.toml',
},
],
checkers: [
{
toolName: 'test-tool',
checker: { type: 'external', name: 'test-checker' },
source: 'Extension (test-extension): Extension: policies.toml',
},
],
};
const inactiveExtension: GeminiCLIExtension = {
name: 'test-extension',
isActive: false,
@@ -67,6 +89,12 @@ describe('SimpleExtensionLoader', () => {
mockHookSystemInit = vi.fn();
mockAgentRegistryReload = vi.fn();
mockSkillsReload = vi.fn();
mockPolicyEngine = {
addRule: vi.fn(),
addChecker: vi.fn(),
removeRulesBySource: vi.fn(),
removeCheckersBySource: vi.fn(),
};
mockConfig = {
getMcpClientManager: () => mockMcpClientManager,
getEnableExtensionReloading: () => extensionReloadingEnabled,
@@ -81,6 +109,7 @@ describe('SimpleExtensionLoader', () => {
reload: mockAgentRegistryReload,
}),
reloadSkills: mockSkillsReload,
getPolicyEngine: () => mockPolicyEngine,
} as unknown as Config;
});
@@ -88,6 +117,29 @@ describe('SimpleExtensionLoader', () => {
vi.restoreAllMocks();
});
it('should register policies when an extension starts', async () => {
const loader = new SimpleExtensionLoader([activeExtension]);
await loader.start(mockConfig);
expect(mockPolicyEngine.addRule).toHaveBeenCalledWith(
activeExtension.rules![0],
);
expect(mockPolicyEngine.addChecker).toHaveBeenCalledWith(
activeExtension.checkers![0],
);
});
it('should unregister policies when an extension stops', async () => {
const loader = new TestingSimpleExtensionLoader([activeExtension]);
await loader.start(mockConfig);
await loader.stopExtension(activeExtension);
expect(mockPolicyEngine.removeRulesBySource).toHaveBeenCalledWith(
'Extension (test-extension): Extension: policies.toml',
);
expect(mockPolicyEngine.removeCheckersBySource).toHaveBeenCalledWith(
'Extension (test-extension): Extension: policies.toml',
);
});
it('should start active extensions', async () => {
const loader = new SimpleExtensionLoader([activeExtension]);
await loader.start(mockConfig);
@@ -75,6 +75,21 @@ export abstract class ExtensionLoader {
await this.config.getMcpClientManager()!.startExtension(extension);
await this.maybeRefreshGeminiTools(extension);
// Register policy rules and checkers
if (extension.rules || extension.checkers) {
const policyEngine = this.config.getPolicyEngine();
if (extension.rules) {
for (const rule of extension.rules) {
policyEngine.addRule(rule);
}
}
if (extension.checkers) {
for (const checker of extension.checkers) {
policyEngine.addChecker(checker);
}
}
}
// Note: Context files are loaded only once all extensions are done
// loading/unloading to reduce churn, see the `maybeRefreshMemories` call
// below.
@@ -168,6 +183,27 @@ export abstract class ExtensionLoader {
await this.config.getMcpClientManager()!.stopExtension(extension);
await this.maybeRefreshGeminiTools(extension);
// Unregister policy rules and checkers
if (extension.rules || extension.checkers) {
const policyEngine = this.config.getPolicyEngine();
const sources = new Set<string>();
if (extension.rules) {
for (const rule of extension.rules) {
if (rule.source) sources.add(rule.source);
}
}
if (extension.checkers) {
for (const checker of extension.checkers) {
if (checker.source) sources.add(checker.source);
}
}
for (const source of sources) {
policyEngine.removeRulesBySource(source);
policyEngine.removeCheckersBySource(source);
}
}
// Note: Context files are loaded only once all extensions are done
// loading/unloading to reduce churn, see the `maybeRefreshMemories` call
// below.
-27
View File
@@ -2129,33 +2129,6 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
============================================================
path-to-regexp@6.3.0
(https://github.com/pillarjs/path-to-regexp.git)
The MIT License (MIT)
Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
============================================================
send@1.2.1
(No repository found)
+1 -1
View File
@@ -31,4 +31,4 @@ To use this extension, you'll need:
# Terms of Service and Privacy Notice
By installing this extension, you agree to the
[Terms of Service](https://geminicli.com/docs/resources/tos-privacy/).
[Terms of Service](https://github.com/google-gemini/gemini-cli/blob/main/docs/tos-privacy.md).
-7
View File
@@ -117,13 +117,6 @@
"description": "The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.",
"markdownDescription": "The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.\n\n- Category: `General`\n- Requires restart: `yes`",
"type": "string"
},
"modelRouting": {
"title": "Plan Model Routing",
"description": "Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase.",
"markdownDescription": "Automatically switch between Pro and Flash models based on Plan Mode status. Uses Pro for the planning phase and Flash for the implementation phase.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `true`",
"default": true,
"type": "boolean"
}
},
"additionalProperties": false