From 5e186bfb22a6c08421dcc8866c7884cbf0f5ae98 Mon Sep 17 00:00:00 2001 From: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com> Date: Wed, 25 Mar 2026 06:46:00 -0700 Subject: [PATCH 01/49] fix(cli): skip console log/info in headless mode (#22739) --- integration-tests/extensions-install.test.ts | 10 +- integration-tests/extensions-reload.test.ts | 2 +- packages/cli/src/gemini.tsx | 2 + packages/cli/src/nonInteractiveCli.ts | 1 + .../cli/src/ui/utils/ConsolePatcher.test.ts | 236 ++++++++++++++++++ packages/cli/src/ui/utils/ConsolePatcher.ts | 18 +- 6 files changed, 260 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/ui/utils/ConsolePatcher.test.ts diff --git a/integration-tests/extensions-install.test.ts b/integration-tests/extensions-install.test.ts index 90dbf1ab0d..e9f1cdbf49 100644 --- a/integration-tests/extensions-install.test.ts +++ b/integration-tests/extensions-install.test.ts @@ -34,16 +34,20 @@ describe('extension install', () => { writeFileSync(testServerPath, extension); try { const result = await rig.runCommand( - ['extensions', 'install', `${rig.testDir!}`], + ['--debug', 'extensions', 'install', `${rig.testDir!}`], { stdin: 'y\n' }, ); expect(result).toContain('test-extension-install'); - const listResult = await rig.runCommand(['extensions', 'list']); + const listResult = await rig.runCommand([ + '--debug', + 'extensions', + 'list', + ]); expect(listResult).toContain('test-extension-install'); writeFileSync(testServerPath, extensionUpdate); const updateResult = await rig.runCommand( - ['extensions', 'update', `test-extension-install`], + ['--debug', 'extensions', 'update', `test-extension-install`], { stdin: 'y\n' }, ); expect(updateResult).toContain('0.0.2'); diff --git a/integration-tests/extensions-reload.test.ts b/integration-tests/extensions-reload.test.ts index 9d451cedcf..ba9bec55e1 100644 --- a/integration-tests/extensions-reload.test.ts +++ b/integration-tests/extensions-reload.test.ts @@ -66,7 +66,7 @@ describe('extension reloading', () => { } const result = await rig.runCommand( - ['extensions', 'install', `${rig.testDir!}`], + ['--debug', 'extensions', 'install', `${rig.testDir!}`], { stdin: 'y\n' }, ); expect(result).toContain('test-extension'); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 5bd9944f63..707774df57 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -32,6 +32,7 @@ import { ValidationRequiredError, type AdminControlsSettings, debugLogger, + isHeadlessMode, } from '@google/gemini-cli-core'; import { loadCliConfig, parseArguments } from './config/config.js'; @@ -296,6 +297,7 @@ export async function main() { const isDebugMode = cliConfig.isDebugMode(argv); const consolePatcher = new ConsolePatcher({ stderr: true, + interactive: isHeadlessMode() ? false : true, debugMode: isDebugMode, onNewMessage: (msg) => { coreEvents.emitConsoleLog(msg.type, msg.content); diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index 891e3d0ee9..4f9d817204 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -65,6 +65,7 @@ export async function runNonInteractive({ return promptIdContext.run(prompt_id, async () => { const consolePatcher = new ConsolePatcher({ stderr: true, + interactive: false, debugMode: config.getDebugMode(), onNewMessage: (msg) => { coreEvents.emitConsoleLog(msg.type, msg.content); diff --git a/packages/cli/src/ui/utils/ConsolePatcher.test.ts b/packages/cli/src/ui/utils/ConsolePatcher.test.ts new file mode 100644 index 0000000000..8439ca3564 --- /dev/null +++ b/packages/cli/src/ui/utils/ConsolePatcher.test.ts @@ -0,0 +1,236 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable no-console */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { ConsolePatcher } from './ConsolePatcher.js'; + +describe('ConsolePatcher', () => { + let patcher: ConsolePatcher; + const onNewMessage = vi.fn(); + + afterEach(() => { + if (patcher) { + patcher.cleanup(); + } + vi.restoreAllMocks(); + vi.clearAllMocks(); + }); + + it('should patch and restore console methods', () => { + const beforeLog = console.log; + const beforeWarn = console.warn; + const beforeError = console.error; + const beforeDebug = console.debug; + const beforeInfo = console.info; + + patcher = new ConsolePatcher({ onNewMessage, debugMode: false }); + patcher.patch(); + + expect(console.log).not.toBe(beforeLog); + expect(console.warn).not.toBe(beforeWarn); + expect(console.error).not.toBe(beforeError); + expect(console.debug).not.toBe(beforeDebug); + expect(console.info).not.toBe(beforeInfo); + + patcher.cleanup(); + + expect(console.log).toBe(beforeLog); + expect(console.warn).toBe(beforeWarn); + expect(console.error).toBe(beforeError); + expect(console.debug).toBe(beforeDebug); + expect(console.info).toBe(beforeInfo); + }); + + describe('Interactive mode', () => { + it('should ignore log and info when it is not interactive and debugMode is false', () => { + patcher = new ConsolePatcher({ + onNewMessage, + debugMode: false, + interactive: false, + }); + patcher.patch(); + + console.log('test log'); + console.info('test info'); + expect(onNewMessage).not.toHaveBeenCalled(); + }); + + it('should not ignore log and info when it is not interactive and debugMode is true', () => { + patcher = new ConsolePatcher({ + onNewMessage, + debugMode: true, + interactive: false, + }); + patcher.patch(); + + console.log('test log'); + expect(onNewMessage).toHaveBeenCalledWith({ + type: 'log', + content: 'test log', + count: 1, + }); + + console.info('test info'); + expect(onNewMessage).toHaveBeenCalledWith({ + type: 'info', + content: 'test info', + count: 1, + }); + }); + + it('should not ignore log and info when it is interactive', () => { + patcher = new ConsolePatcher({ + onNewMessage, + debugMode: false, + interactive: true, + }); + patcher.patch(); + + console.log('test log'); + expect(onNewMessage).toHaveBeenCalledWith({ + type: 'log', + content: 'test log', + count: 1, + }); + + console.info('test info'); + expect(onNewMessage).toHaveBeenCalledWith({ + type: 'info', + content: 'test info', + count: 1, + }); + }); + }); + + describe('when stderr is false', () => { + it('should call onNewMessage for log, warn, error, and info', () => { + patcher = new ConsolePatcher({ + onNewMessage, + debugMode: false, + stderr: false, + }); + patcher.patch(); + + console.log('test log'); + expect(onNewMessage).toHaveBeenCalledWith({ + type: 'log', + content: 'test log', + count: 1, + }); + + console.warn('test warn'); + expect(onNewMessage).toHaveBeenCalledWith({ + type: 'warn', + content: 'test warn', + count: 1, + }); + + console.error('test error'); + expect(onNewMessage).toHaveBeenCalledWith({ + type: 'error', + content: 'test error', + count: 1, + }); + + console.info('test info'); + expect(onNewMessage).toHaveBeenCalledWith({ + type: 'info', + content: 'test info', + count: 1, + }); + }); + + it('should not call onNewMessage for debug when debugMode is false', () => { + patcher = new ConsolePatcher({ + onNewMessage, + debugMode: false, + stderr: false, + }); + patcher.patch(); + + console.debug('test debug'); + expect(onNewMessage).not.toHaveBeenCalled(); + }); + + it('should call onNewMessage for debug when debugMode is true', () => { + patcher = new ConsolePatcher({ + onNewMessage, + debugMode: true, + stderr: false, + }); + patcher.patch(); + + console.debug('test debug'); + expect(onNewMessage).toHaveBeenCalledWith({ + type: 'debug', + content: 'test debug', + count: 1, + }); + }); + + it('should format multiple arguments using util.format', () => { + patcher = new ConsolePatcher({ + onNewMessage, + debugMode: false, + stderr: false, + }); + patcher.patch(); + + console.log('test %s %d', 'string', 123); + expect(onNewMessage).toHaveBeenCalledWith({ + type: 'log', + content: 'test string 123', + count: 1, + }); + }); + }); + + describe('when stderr is true', () => { + it('should redirect warn and error to originalConsoleError', () => { + const spyError = vi.spyOn(console, 'error').mockImplementation(() => {}); + patcher = new ConsolePatcher({ debugMode: false, stderr: true }); + patcher.patch(); + + console.warn('test warn'); + expect(spyError).toHaveBeenCalledWith('test warn'); + + console.error('test error'); + expect(spyError).toHaveBeenCalledWith('test error'); + }); + + it('should redirect log and info to originalConsoleError when debugMode is true', () => { + const spyError = vi.spyOn(console, 'error').mockImplementation(() => {}); + patcher = new ConsolePatcher({ debugMode: true, stderr: true }); + patcher.patch(); + + console.log('test log'); + expect(spyError).toHaveBeenCalledWith('test log'); + + console.info('test info'); + expect(spyError).toHaveBeenCalledWith('test info'); + }); + + it('should ignore debug when debugMode is false', () => { + const spyError = vi.spyOn(console, 'error').mockImplementation(() => {}); + patcher = new ConsolePatcher({ debugMode: false, stderr: true }); + patcher.patch(); + + console.debug('test debug'); + expect(spyError).not.toHaveBeenCalled(); + }); + + it('should redirect debug to originalConsoleError when debugMode is true', () => { + const spyError = vi.spyOn(console, 'error').mockImplementation(() => {}); + patcher = new ConsolePatcher({ debugMode: true, stderr: true }); + patcher.patch(); + + console.debug('test debug'); + expect(spyError).toHaveBeenCalledWith('test debug'); + }); + }); +}); diff --git a/packages/cli/src/ui/utils/ConsolePatcher.ts b/packages/cli/src/ui/utils/ConsolePatcher.ts index 3674c5614e..ddd26fca0b 100644 --- a/packages/cli/src/ui/utils/ConsolePatcher.ts +++ b/packages/cli/src/ui/utils/ConsolePatcher.ts @@ -13,6 +13,7 @@ interface ConsolePatcherParams { onNewMessage?: (message: Omit) => void; debugMode: boolean; stderr?: boolean; + interactive?: boolean; } export class ConsolePatcher { @@ -49,12 +50,19 @@ export class ConsolePatcher { private patchConsoleMethod = (type: 'log' | 'warn' | 'error' | 'debug' | 'info') => (...args: unknown[]) => { - if (this.params.stderr) { - if (type !== 'debug' || this.params.debugMode) { - this.originalConsoleError(this.formatArgs(args)); + // When it is non interactive mode, do not show info logging unless + // it is debug mode. default to true if it is undefined. + if (this.params.interactive === false) { + if ((type === 'info' || type === 'log') && !this.params.debugMode) { + return; } - } else { - if (type !== 'debug' || this.params.debugMode) { + } + // When it is in the debug mode, redirect console output to stderr + // depending on if it is stderr only mode. + if (type !== 'debug' || this.params.debugMode) { + if (this.params.stderr) { + this.originalConsoleError(this.formatArgs(args)); + } else { this.params.onNewMessage?.({ type, content: this.formatArgs(args), From 109a7dc531b1bd92f8cd1688dae2b2f8affe3c65 Mon Sep 17 00:00:00 2001 From: Emily Hedlund Date: Wed, 25 Mar 2026 10:29:46 -0400 Subject: [PATCH 02/49] test(core): install bubblewrap on Linux CI for sandbox integration tests (#23583) --- .github/workflows/ci.yml | 6 +++++ .../sandboxManager.integration.test.ts | 26 ++++++++++++------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 973d88f5f8..1e1f329d5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,6 +158,12 @@ jobs: - name: 'Build project' run: 'npm run build' + - name: 'Install system dependencies' + run: | + sudo apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq bubblewrap + # Ubuntu 24.04+ requires this to allow bwrap to function in CI + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true + - name: 'Install dependencies for testing' run: 'npm ci' diff --git a/packages/core/src/services/sandboxManager.integration.test.ts b/packages/core/src/services/sandboxManager.integration.test.ts index 4cf894cc17..c4bc2f1cc5 100644 --- a/packages/core/src/services/sandboxManager.integration.test.ts +++ b/packages/core/src/services/sandboxManager.integration.test.ts @@ -95,26 +95,34 @@ async function runCommand(command: SandboxedCommand) { /** * Determines if the system has the necessary binaries to run the sandbox. + * Throws an error if a supported platform is missing its required tools. */ -function isSandboxAvailable(): boolean { - if (os.platform() === 'win32') { +function ensureSandboxAvailable(): boolean { + const platform = os.platform(); + + if (platform === 'win32') { // Windows sandboxing relies on icacls, which is a core system utility and // always available. return true; } - if (os.platform() === 'darwin') { - return fs.existsSync('/usr/bin/sandbox-exec'); + if (platform === 'darwin') { + if (fs.existsSync('/usr/bin/sandbox-exec')) { + return true; + } + throw new Error( + 'Sandboxing tests on macOS require /usr/bin/sandbox-exec to be present.', + ); } - if (os.platform() === 'linux') { - // TODO: Install bubblewrap (bwrap) in Linux CI environments to enable full - // integration testing. + if (platform === 'linux') { try { execSync('which bwrap', { stdio: 'ignore' }); return true; } catch { - return false; + throw new Error( + 'Sandboxing tests on Linux require bubblewrap (bwrap) to be installed.', + ); } } @@ -129,7 +137,7 @@ describe('SandboxManager Integration', () => { const shouldSkip = manager instanceof NoopSandboxManager || manager instanceof LocalSandboxManager || - !isSandboxAvailable(); + !ensureSandboxAvailable(); describe.skipIf(shouldSkip)('Cross-platform Sandbox Behavior', () => { describe('Basic Execution', () => { From e667739c04ab32aa3e40c02b176a3f30ab4c9da5 Mon Sep 17 00:00:00 2001 From: Sheikh Limon Date: Wed, 25 Mar 2026 21:11:39 +0600 Subject: [PATCH 03/49] docs(reference): split tools table into category sections (#21516) --- docs/reference/tools.md | 77 +++++++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/docs/reference/tools.md b/docs/reference/tools.md index c72888d072..09f0518c07 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -63,29 +63,62 @@ details. ## Available tools -The following table lists all available tools, categorized by their primary -function. +The following sections list all available tools, categorized by their primary +function. For detailed parameter information, see the linked documentation for +each tool. -| Category | Tool | Kind | Description | -| :---------- | :----------------------------------------------- | :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Execution | [`run_shell_command`](../tools/shell.md) | `Execute` | Executes arbitrary shell commands. Supports interactive sessions and background processes. Requires manual confirmation.

**Parameters:** `command`, `description`, `dir_path`, `is_background` | -| File System | [`glob`](../tools/file-system.md) | `Search` | Finds files matching specific glob patterns across the workspace.

**Parameters:** `pattern`, `dir_path`, `case_sensitive`, `respect_git_ignore`, `respect_gemini_ignore` | -| File System | [`grep_search`](../tools/file-system.md) | `Search` | Searches for a regular expression pattern within file contents. Legacy alias: `search_file_content`.

**Parameters:** `pattern`, `dir_path`, `include`, `exclude_pattern`, `names_only`, `max_matches_per_file`, `total_max_matches` | -| File System | [`list_directory`](../tools/file-system.md) | `Read` | Lists the names of files and subdirectories within a specified path.

**Parameters:** `dir_path`, `ignore`, `file_filtering_options` | -| File System | [`read_file`](../tools/file-system.md) | `Read` | Reads the content of a specific file. Supports text, images, audio, and PDF.

**Parameters:** `file_path`, `start_line`, `end_line` | -| File System | [`read_many_files`](../tools/file-system.md) | `Read` | Reads and concatenates content from multiple files. Often triggered by the `@` symbol in your prompt.

**Parameters:** `include`, `exclude`, `recursive`, `useDefaultExcludes`, `file_filtering_options` | -| File System | [`replace`](../tools/file-system.md) | `Edit` | Performs precise text replacement within a file. Requires manual confirmation.

**Parameters:** `file_path`, `instruction`, `old_string`, `new_string`, `allow_multiple` | -| File System | [`write_file`](../tools/file-system.md) | `Edit` | Creates or overwrites a file with new content. Requires manual confirmation.

**Parameters:** `file_path`, `content` | -| Interaction | [`ask_user`](../tools/ask-user.md) | `Communicate` | Requests clarification or missing information via an interactive dialog.

**Parameters:** `questions` | -| Interaction | [`write_todos`](../tools/todos.md) | `Other` | Maintains an internal list of subtasks. The model uses this to track its own progress and display it to you.

**Parameters:** `todos` | -| Memory | [`activate_skill`](../tools/activate-skill.md) | `Other` | Loads specialized procedural expertise for specific tasks from the `.gemini/skills` directory.

**Parameters:** `name` | -| Memory | [`get_internal_docs`](../tools/internal-docs.md) | `Think` | Accesses Gemini CLI's own documentation to provide more accurate answers about its capabilities.

**Parameters:** `path` | -| Memory | [`save_memory`](../tools/memory.md) | `Think` | Persists specific facts and project details to your `GEMINI.md` file to retain context.

**Parameters:** `fact` | -| Planning | [`enter_plan_mode`](../tools/planning.md) | `Plan` | Switches the CLI to a safe, read-only "Plan Mode" for researching complex changes.

**Parameters:** `reason` | -| Planning | [`exit_plan_mode`](../tools/planning.md) | `Plan` | Finalizes a plan, presents it for review, and requests approval to start implementation.

**Parameters:** `plan` | -| System | `complete_task` | `Other` | Finalizes a subagent's mission and returns the result to the parent agent. This tool is not available to the user.

**Parameters:** `result` | -| Web | [`google_web_search`](../tools/web-search.md) | `Search` | Performs a Google Search to find up-to-date information.

**Parameters:** `query` | -| Web | [`web_fetch`](../tools/web-fetch.md) | `Fetch` | Retrieves and processes content from specific URLs. **Warning:** This tool can access local and private network addresses (e.g., localhost), which may pose a security risk if used with untrusted prompts.

**Parameters:** `prompt` | +### Execution + +| Tool | Kind | Description | +| :--------------------------------------- | :-------- | :----------------------------------------------------------------------------------------------------------------------- | +| [`run_shell_command`](../tools/shell.md) | `Execute` | Executes arbitrary shell commands. Supports interactive sessions and background processes. Requires manual confirmation. | + +### File System + +| Tool | Kind | Description | +| :------------------------------------------- | :------- | :---------------------------------------------------------------------------------------------------- | +| [`glob`](../tools/file-system.md) | `Search` | Finds files matching specific glob patterns across the workspace. | +| [`grep_search`](../tools/file-system.md) | `Search` | Searches for a regular expression pattern within file contents. Legacy alias: `search_file_content`. | +| [`list_directory`](../tools/file-system.md) | `Read` | Lists the names of files and subdirectories within a specified path. | +| [`read_file`](../tools/file-system.md) | `Read` | Reads the content of a specific file. Supports text, images, audio, and PDF. | +| [`read_many_files`](../tools/file-system.md) | `Read` | Reads and concatenates content from multiple files. Often triggered by the `@` symbol in your prompt. | +| [`replace`](../tools/file-system.md) | `Edit` | Performs precise text replacement within a file. Requires manual confirmation. | +| [`write_file`](../tools/file-system.md) | `Edit` | Creates or overwrites a file with new content. Requires manual confirmation. | + +### Interaction + +| Tool | Kind | Description | +| :--------------------------------- | :------------ | :------------------------------------------------------------------------------------- | +| [`ask_user`](../tools/ask-user.md) | `Communicate` | Requests clarification or missing information via an interactive dialog. | +| [`write_todos`](../tools/todos.md) | `Other` | Maintains an internal list of subtasks. The model uses this to track its own progress. | + +### Memory + +| Tool | Kind | Description | +| :----------------------------------------------- | :------ | :----------------------------------------------------------------------------------- | +| [`activate_skill`](../tools/activate-skill.md) | `Other` | Loads specialized procedural expertise from the `.gemini/skills` directory. | +| [`get_internal_docs`](../tools/internal-docs.md) | `Think` | Accesses Gemini CLI's own documentation for accurate answers about its capabilities. | +| [`save_memory`](../tools/memory.md) | `Think` | Persists specific facts and project details to your `GEMINI.md` file. | + +### Planning + +| Tool | Kind | Description | +| :---------------------------------------- | :----- | :--------------------------------------------------------------------------------------- | +| [`enter_plan_mode`](../tools/planning.md) | `Plan` | Switches the CLI to a safe, read-only "Plan Mode" for researching complex changes. | +| [`exit_plan_mode`](../tools/planning.md) | `Plan` | Finalizes a plan, presents it for review, and requests approval to start implementation. | + +### System + +| Tool | Kind | Description | +| :-------------- | :------ | :----------------------------------------------------------------------------------------------------------------- | +| `complete_task` | `Other` | Finalizes a subagent's mission and returns the result to the parent agent. This tool is not available to the user. | + +### Web + +| Tool | Kind | Description | +| :-------------------------------------------- | :------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`google_web_search`](../tools/web-search.md) | `Search` | Performs a Google Search to find up-to-date information. | +| [`web_fetch`](../tools/web-fetch.md) | `Fetch` | Retrieves and processes content from specific URLs. **Warning:** This tool can access local and private network addresses (e.g., localhost), which may pose a security risk if used with untrusted prompts. | ## Under the hood From bbf5c2fe95d67a93c6fa64cbb11a7383296a8bea Mon Sep 17 00:00:00 2001 From: tony-shi Date: Wed, 25 Mar 2026 23:26:00 +0800 Subject: [PATCH 04/49] fix(browser): detect embedded URLs in query params to prevent allowedDomains bypass (#23225) Co-authored-by: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com> --- .../src/agents/browser/browserManager.test.ts | 70 +++++++++++++++++++ .../core/src/agents/browser/browserManager.ts | 60 ++++++++++++---- 2 files changed, 118 insertions(+), 12 deletions(-) diff --git a/packages/core/src/agents/browser/browserManager.test.ts b/packages/core/src/agents/browser/browserManager.test.ts index 303c07288d..c38457e4aa 100644 --- a/packages/core/src/agents/browser/browserManager.test.ts +++ b/packages/core/src/agents/browser/browserManager.test.ts @@ -272,6 +272,76 @@ describe('BrowserManager', () => { expect(result.isError).toBe(true); expect((result.content || [])[0]?.text).toContain('not permitted'); }); + + it('should block proxy URL with embedded disallowed domain in query params', async () => { + const restrictedConfig = makeFakeConfig({ + agents: { + browser: { + allowedDomains: ['*.google.com'], + }, + }, + }); + const manager = new BrowserManager(restrictedConfig); + const result = await manager.callTool('new_page', { + url: 'https://translate.google.com/translate?sl=en&tl=en&u=https://blocked.org/page', + }); + + expect(result.isError).toBe(true); + expect((result.content || [])[0]?.text).toContain( + 'an embedded URL targets a disallowed domain', + ); + }); + + it('should block proxy URL with embedded disallowed domain in URL fragment (hash)', async () => { + const restrictedConfig = makeFakeConfig({ + agents: { + browser: { + allowedDomains: ['*.google.com'], + }, + }, + }); + const manager = new BrowserManager(restrictedConfig); + const result = await manager.callTool('new_page', { + url: 'https://translate.google.com/#view=home&op=translate&sl=en&tl=zh-CN&u=https://blocked.org', + }); + + expect(result.isError).toBe(true); + expect((result.content || [])[0]?.text).toContain( + 'an embedded URL targets a disallowed domain', + ); + }); + + it('should allow proxy URL when embedded domain is also allowed', async () => { + const restrictedConfig = makeFakeConfig({ + agents: { + browser: { + allowedDomains: ['*.google.com', 'github.com'], + }, + }, + }); + const manager = new BrowserManager(restrictedConfig); + const result = await manager.callTool('new_page', { + url: 'https://translate.google.com/translate?u=https://github.com/repo', + }); + + expect(result.isError).toBe(false); + }); + + it('should allow navigation to allowed domain without proxy params', async () => { + const restrictedConfig = makeFakeConfig({ + agents: { + browser: { + allowedDomains: ['*.google.com'], + }, + }, + }); + const manager = new BrowserManager(restrictedConfig); + const result = await manager.callTool('new_page', { + url: 'https://translate.google.com/?sl=en&tl=zh', + }); + + expect(result.isError).toBe(false); + }); }); describe('MCP connection', () => { diff --git a/packages/core/src/agents/browser/browserManager.ts b/packages/core/src/agents/browser/browserManager.ts index cc059feea3..4eb9c2b19c 100644 --- a/packages/core/src/agents/browser/browserManager.ts +++ b/packages/core/src/agents/browser/browserManager.ts @@ -610,29 +610,65 @@ export class BrowserManager { try { const parsedUrl = new URL(url); - const urlHostname = parsedUrl.hostname.replace(/\.$/, ''); + const urlHostname = parsedUrl.hostname; - for (const domainPattern of allowedDomains) { - if (domainPattern.startsWith('*.')) { - const baseDomain = domainPattern.substring(2); + if (!this.isDomainAllowed(urlHostname, allowedDomains)) { + // If none matched, then deny + return `Tool '${toolName}' is not permitted for the requested URL/domain based on your current browser settings.`; + } + + // Check query parameters for embedded URLs that could bypass domain + // restrictions via proxy services (e.g. translate.google.com/translate?u=BLOCKED). + const paramsToCheck = [ + ...parsedUrl.searchParams.values(), + // Also check fragments which might contain query-like params + ...new URLSearchParams(parsedUrl.hash.replace(/^#/, '')).values(), + ]; + for (const paramValue of paramsToCheck) { + try { + const embeddedUrl = new URL(paramValue); if ( - urlHostname === baseDomain || - urlHostname.endsWith(`.${baseDomain}`) + embeddedUrl.protocol === 'http:' || + embeddedUrl.protocol === 'https:' ) { - return undefined; - } - } else { - if (urlHostname === domainPattern) { - return undefined; + const embeddedHostname = embeddedUrl.hostname.replace(/\.$/, ''); + if (!this.isDomainAllowed(embeddedHostname, allowedDomains)) { + return `Tool '${toolName}' is not permitted: an embedded URL targets a disallowed domain.`; + } } + } catch { + // Not a valid URL, skip. } } + + return undefined; } catch { return `Invalid URL: Malformed URL string.`; } + } + /** + * Checks whether a hostname matches any pattern in the allowed domains list. + */ + private isDomainAllowed(hostname: string, allowedDomains: string[]): boolean { + const normalized = hostname.replace(/\.$/, ''); + for (const domainPattern of allowedDomains) { + if (domainPattern.startsWith('*.')) { + const baseDomain = domainPattern.substring(2); + if ( + normalized === baseDomain || + normalized.endsWith(`.${baseDomain}`) + ) { + return true; + } + } else { + if (normalized === domainPattern) { + return true; + } + } + } // If none matched, then deny - return `Tool '${toolName}' is not permitted for the requested URL/domain based on your current browser settings.`; + return false; } /** From 6deee114498dfa8e9bc968c07c161cd2532c62b9 Mon Sep 17 00:00:00 2001 From: tony-shi Date: Wed, 25 Mar 2026 23:59:21 +0800 Subject: [PATCH 05/49] fix(browser): add proxy bypass constraint to domain restriction system prompt (#23229) Co-authored-by: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com> --- packages/core/src/agents/browser/browserAgentDefinition.ts | 2 +- packages/core/src/agents/browser/browserAgentFactory.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/core/src/agents/browser/browserAgentDefinition.ts b/packages/core/src/agents/browser/browserAgentDefinition.ts index b04b2a3ede..7deee9f94c 100644 --- a/packages/core/src/agents/browser/browserAgentDefinition.ts +++ b/packages/core/src/agents/browser/browserAgentDefinition.ts @@ -73,7 +73,7 @@ export function buildBrowserSystemPrompt( .map((d) => `- ${d}`) .join( '\n', - )}\nDo NOT attempt to navigate to any other domains using new_page or navigate_page, as it will be rejected. This is a hard security constraint.` + )}\nDo NOT attempt to navigate to any other domains using new_page or navigate_page, as it will be rejected. This is a hard security constraint.\nDo NOT use proxy services (e.g. Google Translate, Google AMP, or any URL translation/caching service) to access content from domains outside this list. Embedding a blocked URL as a parameter of an allowed-domain service is a direct violation of this security restriction.` : ''; return `You are an expert browser automation agent (Orchestrator). Your goal is to completely fulfill the user's request.${allowedDomainsInstruction} diff --git a/packages/core/src/agents/browser/browserAgentFactory.test.ts b/packages/core/src/agents/browser/browserAgentFactory.test.ts index aec09dc6af..270b400c3b 100644 --- a/packages/core/src/agents/browser/browserAgentFactory.test.ts +++ b/packages/core/src/agents/browser/browserAgentFactory.test.ts @@ -467,6 +467,7 @@ describe('buildBrowserSystemPrompt', () => { expect(prompt).toContain('SECURITY DOMAIN RESTRICTION - CRITICAL:'); expect(prompt).toContain('- github.com'); expect(prompt).toContain('- *.google.com'); + expect(prompt).toContain('Do NOT use proxy services'); }); it('should exclude allowed domains restriction when not provided or empty', () => { From 028d0368d5122f1403ba11884b5fc5a6d2fafec7 Mon Sep 17 00:00:00 2001 From: Adib234 <30782825+Adib234@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:06:45 -0400 Subject: [PATCH 06/49] fix(policy): relax write_file argsPattern in plan mode to allow paths without session ID (#23695) --- integration-tests/plan-mode.test.ts | 150 ++++++++++++-------- packages/core/src/policy/policies/plan.toml | 10 ++ 2 files changed, 102 insertions(+), 58 deletions(-) diff --git a/integration-tests/plan-mode.test.ts b/integration-tests/plan-mode.test.ts index 8709aac189..977a754f1e 100644 --- a/integration-tests/plan-mode.test.ts +++ b/integration-tests/plan-mode.test.ts @@ -4,10 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { writeFileSync } from 'node:fs'; -import { join } from 'node:path'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { TestRig, checkModelOutputContent, GEMINI_DIR } from './test-helper.js'; +import { TestRig, checkModelOutputContent } from './test-helper.js'; describe('Plan Mode', () => { let rig: TestRig; @@ -36,27 +34,23 @@ describe('Plan Mode', () => { }, ); - // 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.', + args: '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(); + const shellLog = toolLogs.find( + (l) => l.toolRequest.name === 'run_shell_command', + ); + expect(lsLog, 'Expected list_directory to be called').toBeDefined(); expect(lsLog?.toolRequest.success).toBe(true); + expect( + shellLog, + 'Expected run_shell_command to be blocked (not even called)', + ).toBeUndefined(); checkModelOutputContent(result, { expectedContent: ['Plan Mode', 'read-only'], @@ -84,23 +78,11 @@ describe('Plan Mode', () => { }, }); - // Disable the interactive terminal setup prompt in tests - writeFileSync( - join(rig.homeDir!, GEMINI_DIR, 'state.json'), - JSON.stringify({ terminalSetupPromptShown: true }, null, 2), - ); - - const run = await rig.runInteractive({ + await rig.run({ approvalMode: 'plan', + args: 'Create a file called plan.md in the plans directory.', }); - await run.type('Create a file called plan.md in the plans directory.'); - await run.type('\r'); - - await rig.expectToolCallSuccess(['write_file'], 30000, (args) => - args.includes('plan.md'), - ); - const toolLogs = rig.readToolLogs(); const planWrite = toolLogs.find( (l) => @@ -108,7 +90,25 @@ describe('Plan Mode', () => { l.toolRequest.args.includes('plans') && l.toolRequest.args.includes('plan.md'), ); - expect(planWrite?.toolRequest.success).toBe(true); + + if (!planWrite) { + console.error( + 'All tool calls found:', + toolLogs.map((l) => ({ + name: l.toolRequest.name, + args: l.toolRequest.args, + })), + ); + } + + expect( + planWrite, + 'Expected write_file to be called for plan.md', + ).toBeDefined(); + expect( + planWrite?.toolRequest.success, + `Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`, + ).toBe(true); }); it('should deny write_file to non-plans directory in plan mode', async () => { @@ -131,19 +131,11 @@ describe('Plan Mode', () => { }, }); - // Disable the interactive terminal setup prompt in tests - writeFileSync( - join(rig.homeDir!, GEMINI_DIR, 'state.json'), - JSON.stringify({ terminalSetupPromptShown: true }, null, 2), - ); - - const run = await rig.runInteractive({ + await rig.run({ approvalMode: 'plan', + args: 'Create a file called hello.txt in the current directory.', }); - await run.type('Create a file called hello.txt in the current directory.'); - await run.type('\r'); - const toolLogs = rig.readToolLogs(); const writeLog = toolLogs.find( (l) => @@ -151,10 +143,11 @@ describe('Plan Mode', () => { l.toolRequest.args.includes('hello.txt'), ); - // In Plan Mode, writes outside the plans directory should be blocked. - // Model is undeterministic, sometimes it doesn't even try, but if it does, it must fail. if (writeLog) { - expect(writeLog.toolRequest.success).toBe(false); + expect( + writeLog.toolRequest.success, + 'Expected write_file to non-plans dir to fail', + ).toBe(false); } }); @@ -169,28 +162,69 @@ describe('Plan Mode', () => { }, }); - // Disable the interactive terminal setup prompt in tests - writeFileSync( - join(rig.homeDir!, GEMINI_DIR, 'state.json'), - JSON.stringify({ terminalSetupPromptShown: true }, null, 2), - ); - - // 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.', + args: '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'); - 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, 'Expected enter_plan_mode to be called').toBeDefined(); expect(enterLog?.toolRequest.success).toBe(true); }); + + it('should allow write_file to the plans directory in plan mode even without a session ID', async () => { + const plansDir = '.gemini/tmp/foo/plans'; + const testName = + 'should allow write_file to the plans directory in plan mode even without a session ID'; + + await rig.setup(testName, { + settings: { + experimental: { plan: true }, + tools: { + core: ['write_file', 'read_file', 'list_directory'], + }, + general: { + defaultApprovalMode: 'plan', + plan: { + directory: plansDir, + }, + }, + }, + }); + + await rig.run({ + approvalMode: 'plan', + args: 'Create a file called plan-no-session.md in the plans directory.', + }); + + const toolLogs = rig.readToolLogs(); + const planWrite = toolLogs.find( + (l) => + l.toolRequest.name === 'write_file' && + l.toolRequest.args.includes('plans') && + l.toolRequest.args.includes('plan-no-session.md'), + ); + + if (!planWrite) { + console.error( + 'All tool calls found:', + toolLogs.map((l) => ({ + name: l.toolRequest.name, + args: l.toolRequest.args, + })), + ); + } + + expect( + planWrite, + 'Expected write_file to be called for plan-no-session.md', + ).toBeDefined(); + expect( + planWrite?.toolRequest.success, + `Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`, + ).toBe(true); + }); }); diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index b6ddef72ef..7627010662 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -110,6 +110,8 @@ priority = 70 modes = ["plan"] # Allow write_file and replace for .md files in the plans directory (cross-platform) +# We split this into two rules to avoid ReDoS checker issues with nested optional segments. +# This rule handles the case where there is a session ID in the plan file path [[rule]] toolName = ["write_file", "replace"] decision = "allow" @@ -117,6 +119,14 @@ priority = 70 modes = ["plan"] argsPattern = "\\x00\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00" +# This rule handles the case where there isn't a session ID in the plan file path +[[rule]] +toolName = ["write_file", "replace"] +decision = "allow" +priority = 70 +modes = ["plan"] +argsPattern = "\\x00\"file_path\":\"[^\"]+[\\\\/]+\\.gemini[\\\\/]+tmp[\\\\/]+[\\w-]+[\\\\/]+plans[\\\\/]+[\\w-]+\\.md\"\\x00" + # Explicitly Deny other write operations in Plan mode with a clear message. [[rule]] toolName = ["write_file", "replace"] From ec953426dbce94ea48412b9882bd0ec6583cb123 Mon Sep 17 00:00:00 2001 From: splint-disk-8i <259054981+splint-disk-8i@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:49:58 +0200 Subject: [PATCH 07/49] docs: fix grammar in CONTRIBUTING and numbering in sandbox docs (#23448) Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com> --- CONTRIBUTING.md | 4 ++-- docs/cli/sandbox.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c6c619219c..9b3e18d6af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -323,8 +323,8 @@ fi #### Formatting -To separately format the code in this project by running the following command -from the root directory: +To separately format the code in this project, run the following command from +the root directory: ```bash npm run format diff --git a/docs/cli/sandbox.md b/docs/cli/sandbox.md index b34433a878..e27587abf0 100644 --- a/docs/cli/sandbox.md +++ b/docs/cli/sandbox.md @@ -92,7 +92,7 @@ To set up runsc: 2. Configure the Docker daemon to use the runsc runtime. 3. Verify the installation. -### 4. LXC/LXD (Linux only, experimental) +### 5. LXC/LXD (Linux only, experimental) Full-system container sandboxing using LXC/LXD. Unlike Docker/Podman, LXC containers run a complete Linux system with `systemd`, `snapd`, and other system From c06794b3c671c3c51ee705456b08ed6a18166228 Mon Sep 17 00:00:00 2001 From: Sri Pasumarthi <111310667+sripasg@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:52:21 -0700 Subject: [PATCH 08/49] fix(acp): allow attachments by adding a permission prompt (#23680) --- packages/cli/src/acp/acpClient.test.ts | 183 +++++++++++++++++- packages/cli/src/acp/acpClient.ts | 250 +++++++++++++++++++++++-- 2 files changed, 412 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/acp/acpClient.test.ts b/packages/cli/src/acp/acpClient.test.ts index 3ae71e6ebb..e10f0e3e3d 100644 --- a/packages/cli/src/acp/acpClient.test.ts +++ b/packages/cli/src/acp/acpClient.test.ts @@ -21,13 +21,13 @@ import { AuthType, ToolConfirmationOutcome, StreamEventType, - isWithinRoot, ReadManyFilesTool, type GeminiChat, type Config, type MessageBus, LlmRole, type GitService, + processSingleFileContent, } from '@google/gemini-cli-core'; import { SettingScope, @@ -111,7 +111,6 @@ vi.mock( }), })), logToolCall: vi.fn(), - isWithinRoot: vi.fn().mockReturnValue(true), LlmRole: { MAIN: 'main', SUBAGENT: 'subagent', @@ -134,6 +133,7 @@ vi.mock( Cancelled: 'cancelled', AwaitingApproval: 'awaiting_approval', }, + processSingleFileContent: vi.fn(), }; }, ); @@ -177,6 +177,10 @@ describe('GeminiAgent', () => { getHasAccessToPreviewModel: vi.fn().mockReturnValue(false), getCheckpointingEnabled: vi.fn().mockReturnValue(false), getDisableAlwaysAllow: vi.fn().mockReturnValue(false), + validatePathAccess: vi.fn().mockReturnValue(null), + getWorkspaceContext: vi.fn().mockReturnValue({ + addReadOnlyPath: vi.fn(), + }), get config() { return this; }, @@ -191,6 +195,7 @@ describe('GeminiAgent', () => { mockArgv = {} as unknown as CliArgs; mockConnection = { sessionUpdate: vi.fn(), + requestPermission: vi.fn(), } as unknown as Mocked; (loadCliConfig as unknown as Mock).mockResolvedValue(mockConfig); @@ -648,6 +653,7 @@ describe('Session', () => { shouldIgnoreFile: vi.fn().mockReturnValue(false), }), getFileFilteringOptions: vi.fn().mockReturnValue({}), + getFileSystemService: vi.fn().mockReturnValue({}), getTargetDir: vi.fn().mockReturnValue('/tmp'), getEnableRecursiveFileSearch: vi.fn().mockReturnValue(false), getDebugMode: vi.fn().mockReturnValue(false), @@ -657,6 +663,10 @@ describe('Session', () => { isPlanEnabled: vi.fn().mockReturnValue(true), getCheckpointingEnabled: vi.fn().mockReturnValue(false), getGitService: vi.fn().mockResolvedValue({} as GitService), + validatePathAccess: vi.fn().mockReturnValue(null), + getWorkspaceContext: vi.fn().mockReturnValue({ + addReadOnlyPath: vi.fn(), + }), waitForMcpInit: vi.fn(), getDisableAlwaysAllow: vi.fn().mockReturnValue(false), get config() { @@ -1356,7 +1366,6 @@ describe('Session', () => { (fs.stat as unknown as Mock).mockResolvedValue({ isDirectory: () => false, }); - (isWithinRoot as unknown as Mock).mockReturnValue(true); const stream = createMockStream([ { @@ -1414,7 +1423,6 @@ describe('Session', () => { (fs.stat as unknown as Mock).mockResolvedValue({ isDirectory: () => false, }); - (isWithinRoot as unknown as Mock).mockReturnValue(true); const MockReadManyFilesTool = ReadManyFilesTool as unknown as Mock; MockReadManyFilesTool.mockImplementationOnce(() => ({ @@ -1468,6 +1476,172 @@ describe('Session', () => { ); }); + it('should handle @path validation error and bubble it to user', async () => { + mockConfig.getTargetDir.mockReturnValue('/workspace'); + (path.resolve as unknown as Mock).mockReturnValue('/tmp/disallowed.txt'); + mockConfig.validatePathAccess.mockReturnValue('Path is outside workspace'); + + // Force fs.stat to fail to skip direct reading and triggers the warning + (fs.stat as unknown as Mock).mockRejectedValue(new Error('File not found')); + + const stream = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + mockChat.sendMessageStream.mockResolvedValue(stream); + + await session.prompt({ + sessionId: 'session-1', + prompt: [ + { + type: 'resource_link', + uri: 'file://disallowed.txt', + mimeType: 'text/plain', + name: 'disallowed.txt', + }, + ], + }); + + // Verify warning sent via sendUpdate + expect(mockConnection.sessionUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: 'agent_thought_chunk', + content: expect.objectContaining({ + text: expect.stringContaining( + 'Warning: skipping access to `disallowed.txt`. Reason: Path is outside workspace', + ), + }), + }), + }), + ); + }); + + it('should read absolute file directly if outside workspace', async () => { + mockConfig.getTargetDir.mockReturnValue('/workspace'); + const testFilePath = '/tmp/custom.txt'; + (path.resolve as unknown as Mock).mockReturnValue(testFilePath); + mockConfig.validatePathAccess.mockReturnValue('Path is outside workspace'); + + mockConnection.requestPermission.mockResolvedValue({ + outcome: { + outcome: 'selected', + optionId: ToolConfirmationOutcome.ProceedOnce, + }, + } as unknown as acp.RequestPermissionResponse); + + const mockStats = { + isFile: () => true, + isDirectory: () => false, + }; + (fs.stat as unknown as Mock).mockResolvedValue(mockStats); + (processSingleFileContent as unknown as Mock).mockResolvedValue({ + llmContent: 'Absolute File Content', + }); + + const stream = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + mockChat.sendMessageStream.mockResolvedValue(stream); + + await session.prompt({ + sessionId: 'session-1', + prompt: [ + { + type: 'resource_link', + uri: `file://${testFilePath}`, + mimeType: 'text/plain', + name: 'custom.txt', + }, + ], + }); + + expect(processSingleFileContent).toHaveBeenCalledWith( + testFilePath, + expect.anything(), + expect.anything(), + ); + + // Verify content appended to sendMessageStream parts + expect(mockChat.sendMessageStream).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([ + expect.objectContaining({ + text: 'Absolute File Content', + }), + ]), + expect.anything(), + expect.any(AbortSignal), + expect.anything(), + ); + }); + + it('should read escaping relative file directly if outside workspace', async () => { + mockConfig.getTargetDir.mockReturnValue('/workspace'); + const testFilePath = '../../custom.txt'; + (path.resolve as unknown as Mock).mockReturnValue('/custom.txt'); + mockConfig.validatePathAccess.mockReturnValue('Path is outside workspace'); + + mockConnection.requestPermission.mockResolvedValue({ + outcome: { + outcome: 'selected', + optionId: ToolConfirmationOutcome.ProceedOnce, + }, + } as unknown as acp.RequestPermissionResponse); + + const mockStats = { + isFile: () => true, + isDirectory: () => false, + }; + (fs.stat as unknown as Mock).mockResolvedValue(mockStats); + (processSingleFileContent as unknown as Mock).mockResolvedValue({ + llmContent: 'Escaping Relative File Content', + }); + + const stream = createMockStream([ + { + type: StreamEventType.CHUNK, + value: { candidates: [] }, + }, + ]); + mockChat.sendMessageStream.mockResolvedValue(stream); + + await session.prompt({ + sessionId: 'session-1', + prompt: [ + { + type: 'resource_link', + uri: `file://${testFilePath}`, + mimeType: 'text/plain', + name: 'custom.txt', + }, + ], + }); + + expect(processSingleFileContent).toHaveBeenCalledWith( + '/custom.txt', + expect.any(String), + expect.anything(), + ); + + expect(mockChat.sendMessageStream).toHaveBeenCalledWith( + expect.anything(), + expect.arrayContaining([ + expect.objectContaining({ + text: 'Escaping Relative File Content', + }), + ]), + expect.anything(), + expect.any(AbortSignal), + expect.anything(), + ); + }); + it('should handle cancellation during prompt', async () => { let streamController: ReadableStreamDefaultController; const stream = new ReadableStream({ @@ -1666,7 +1840,6 @@ describe('Session', () => { (fs.stat as unknown as Mock).mockResolvedValue({ isDirectory: () => true, }); - (isWithinRoot as unknown as Mock).mockReturnValue(true); const stream = createMockStream([ { diff --git a/packages/cli/src/acp/acpClient.ts b/packages/cli/src/acp/acpClient.ts index 57903822e9..1a300413b0 100644 --- a/packages/cli/src/acp/acpClient.ts +++ b/packages/cli/src/acp/acpClient.ts @@ -47,6 +47,7 @@ import { DEFAULT_GEMINI_MODEL_AUTO, PREVIEW_GEMINI_MODEL_AUTO, getDisplayString, + processSingleFileContent, type AgentLoopContext, } from '@google/gemini-cli-core'; import * as acp from '@agentclientprotocol/sdk'; @@ -73,6 +74,17 @@ import { runExitCleanup } from '../utils/cleanup.js'; import { SessionSelector } from '../utils/sessionUtils.js'; import { CommandHandler } from './commandHandler.js'; + +const RequestPermissionResponseSchema = z.object({ + outcome: z.discriminatedUnion('outcome', [ + z.object({ outcome: z.literal('cancelled') }), + z.object({ + outcome: z.literal('selected'), + optionId: z.string(), + }), + ]), +}); + export async function runAcpClient( config: Config, settings: LoadedSettings, @@ -1011,10 +1023,12 @@ export class Session { }, }; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const output = await this.connection.requestPermission(params); + const output = RequestPermissionResponseSchema.parse( + await this.connection.requestPermission(params), + ); + const outcome = - output.outcome.outcome === CoreToolCallStatus.Cancelled + output.outcome.outcome === 'cancelled' ? ToolConfirmationOutcome.Cancel : z .nativeEnum(ToolConfirmationOutcome) @@ -1225,6 +1239,11 @@ export class Session { const pathSpecsToRead: string[] = []; const contentLabelsForDisplay: string[] = []; const ignoredPaths: string[] = []; + const directContents: Array<{ + spec: string; + content?: string; + part?: Part; + }> = []; const toolRegistry = this.context.toolRegistry; const readManyFilesTool = new ReadManyFilesTool( @@ -1247,28 +1266,197 @@ export class Session { } let currentPathSpec = pathName; let resolvedSuccessfully = false; + let readDirectly = false; try { const absolutePath = path.resolve( this.context.config.getTargetDir(), pathName, ); - if (isWithinRoot(absolutePath, this.context.config.getTargetDir())) { - const stats = await fs.stat(absolutePath); - if (stats.isDirectory()) { - currentPathSpec = pathName.endsWith('/') - ? `${pathName}**` - : `${pathName}/**`; + + let validationError = this.context.config.validatePathAccess( + absolutePath, + 'read', + ); + + // We ask the user for explicit permission to read them if outside sandboxed workspace boundaries (and not already authorized). + if ( + validationError && + !isWithinRoot(absolutePath, this.context.config.getTargetDir()) + ) { + try { + const stats = await fs.stat(absolutePath); + if (stats.isFile()) { + const syntheticCallId = `resolve-prompt-${pathName}-${randomUUID()}`; + const params = { + sessionId: this.id, + options: [ + { + optionId: ToolConfirmationOutcome.ProceedOnce, + name: 'Allow once', + kind: 'allow_once', + }, + { + optionId: ToolConfirmationOutcome.Cancel, + name: 'Deny', + kind: 'reject_once', + }, + ] as acp.PermissionOption[], + toolCall: { + toolCallId: syntheticCallId, + status: 'pending', + title: `Allow access to absolute path: ${pathName}`, + content: [ + { + type: 'content', + content: { + type: 'text', + text: `The Agent needs access to read an attached file outside your workspace: ${pathName}`, + }, + }, + ], + locations: [], + kind: 'read', + }, + }; + + const output = RequestPermissionResponseSchema.parse( + await this.connection.requestPermission(params), + ); + + const outcome = + output.outcome.outcome === 'cancelled' + ? ToolConfirmationOutcome.Cancel + : z + .nativeEnum(ToolConfirmationOutcome) + .parse(output.outcome.optionId); + + if (outcome === ToolConfirmationOutcome.ProceedOnce) { + this.context.config + .getWorkspaceContext() + .addReadOnlyPath(absolutePath); + validationError = null; + } else { + this.debug( + `Direct read authorization denied for absolute path ${pathName}`, + ); + directContents.push({ + spec: pathName, + content: `[Warning: Access to absolute path \`${pathName}\` denied by user.]`, + }); + continue; + } + } + } catch (error) { this.debug( - `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`, + `Failed to request permission for absolute attachment ${pathName}: ${getErrorMessage(error)}`, ); - } else { - this.debug(`Path ${pathName} resolved to file: ${currentPathSpec}`); + await this.sendUpdate({ + sessionUpdate: 'agent_thought_chunk', + content: { + type: 'text', + text: `Warning: Failed to display permission dialog for \`${absolutePath}\`. Error: ${getErrorMessage(error)}`, + }, + }); + } + } + + if (!validationError) { + // If it's an absolute path that is authorized (e.g. added via readOnlyPaths), + // read it directly to avoid ReadManyFilesTool absolute path resolution issues. + if ( + (path.isAbsolute(pathName) || + !isWithinRoot( + absolutePath, + this.context.config.getTargetDir(), + )) && + !readDirectly + ) { + try { + const stats = await fs.stat(absolutePath); + if (stats.isFile()) { + const fileReadResult = await processSingleFileContent( + absolutePath, + this.context.config.getTargetDir(), + this.context.config.getFileSystemService(), + ); + + if (!fileReadResult.error) { + if ( + typeof fileReadResult.llmContent === 'object' && + 'inlineData' in fileReadResult.llmContent + ) { + directContents.push({ + spec: pathName, + part: fileReadResult.llmContent, + }); + } else if (typeof fileReadResult.llmContent === 'string') { + let contentToPush = fileReadResult.llmContent; + if (fileReadResult.isTruncated) { + contentToPush = `[WARNING: This file was truncated]\n\n${contentToPush}`; + } + directContents.push({ + spec: pathName, + content: contentToPush, + }); + } + readDirectly = true; + resolvedSuccessfully = true; + } else { + this.debug( + `Direct read failed for absolute path ${pathName}: ${fileReadResult.error}`, + ); + await this.sendUpdate({ + sessionUpdate: 'agent_thought_chunk', + content: { + type: 'text', + text: `Warning: file read failed for \`${pathName}\`. Reason: ${fileReadResult.error}`, + }, + }); + continue; + } + } + } catch (error) { + this.debug( + `File stat/access error for absolute path ${pathName}: ${getErrorMessage(error)}`, + ); + await this.sendUpdate({ + sessionUpdate: 'agent_thought_chunk', + content: { + type: 'text', + text: `Warning: file access failed for \`${pathName}\`. Reason: ${getErrorMessage(error)}`, + }, + }); + continue; + } + } + + if (!readDirectly) { + const stats = await fs.stat(absolutePath); + if (stats.isDirectory()) { + currentPathSpec = pathName.endsWith('/') + ? `${pathName}**` + : `${pathName}/**`; + this.debug( + `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`, + ); + } else { + this.debug( + `Path ${pathName} resolved to file: ${currentPathSpec}`, + ); + } + resolvedSuccessfully = true; } - resolvedSuccessfully = true; } else { this.debug( - `Path ${pathName} is outside the project directory. Skipping.`, + `Path ${pathName} access disallowed: ${validationError}. Skipping.`, ); + await this.sendUpdate({ + sessionUpdate: 'agent_thought_chunk', + content: { + type: 'text', + text: `Warning: skipping access to \`${pathName}\`. Reason: ${validationError}`, + }, + }); } } catch (error) { if (isNodeError(error) && error.code === 'ENOENT') { @@ -1328,7 +1516,9 @@ export class Session { } } if (resolvedSuccessfully) { - pathSpecsToRead.push(currentPathSpec); + if (!readDirectly) { + pathSpecsToRead.push(currentPathSpec); + } atPathToResolvedSpecMap.set(pathName, currentPathSpec); contentLabelsForDisplay.push(pathName); } @@ -1389,7 +1579,11 @@ export class Session { const processedQueryParts: Part[] = [{ text: initialQueryText }]; - if (pathSpecsToRead.length === 0 && embeddedContext.length === 0) { + if ( + pathSpecsToRead.length === 0 && + embeddedContext.length === 0 && + directContents.length === 0 + ) { // Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText debugLogger.warn('No valid file paths found in @ commands to read.'); return [{ text: initialQueryText }]; @@ -1481,6 +1675,30 @@ export class Session { } } + if (directContents.length > 0) { + const hasReferenceStart = processedQueryParts.some( + (p) => + 'text' in p && + typeof p.text === 'string' && + p.text.includes(REFERENCE_CONTENT_START), + ); + if (!hasReferenceStart) { + processedQueryParts.push({ + text: `\n${REFERENCE_CONTENT_START}`, + }); + } + for (const item of directContents) { + processedQueryParts.push({ + text: `\nContent from @${item.spec}:\n`, + }); + if (item.content) { + processedQueryParts.push({ text: item.content }); + } else if (item.part) { + processedQueryParts.push(item.part); + } + } + } + if (embeddedContext.length > 0) { processedQueryParts.push({ text: '\n--- Content from referenced context ---', From f11bd3d0797f1626929b9d095efcb5816c18900c Mon Sep 17 00:00:00 2001 From: Shaswat Raj Date: Wed, 25 Mar 2026 22:27:59 +0530 Subject: [PATCH 09/49] fix(core): thread AbortSignal to chat compression requests (#20405) (#20778) Co-authored-by: Tommaso Sciortino --- packages/core/src/agents/local-executor.ts | 4 +++- packages/core/src/core/client.ts | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/core/src/agents/local-executor.ts b/packages/core/src/agents/local-executor.ts index ed26f634a0..2a47036486 100644 --- a/packages/core/src/agents/local-executor.ts +++ b/packages/core/src/agents/local-executor.ts @@ -323,7 +323,7 @@ export class LocalAgentExecutor { ): Promise { const promptId = `${this.agentId}#${turnCounter}`; - await this.tryCompressChat(chat, promptId); + await this.tryCompressChat(chat, promptId, combinedSignal); const { functionCalls } = await promptIdContext.run(promptId, async () => this.callModel(chat, currentMessage, combinedSignal, promptId), @@ -810,6 +810,7 @@ export class LocalAgentExecutor { private async tryCompressChat( chat: GeminiChat, prompt_id: string, + abortSignal?: AbortSignal, ): Promise { const model = this.definition.modelConfig.model ?? DEFAULT_GEMINI_MODEL; @@ -820,6 +821,7 @@ export class LocalAgentExecutor { model, this.context.config, this.hasFailedCompressionAttempt, + abortSignal, ); if ( diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index f357a0decb..443a663219 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -608,7 +608,7 @@ export class GeminiClient { // Check for context window overflow const modelForLimitCheck = this._getActiveModelForCurrentTurn(); - const compressed = await this.tryCompressChat(prompt_id, false); + const compressed = await this.tryCompressChat(prompt_id, false, signal); if (compressed.compressionStatus === CompressionStatus.COMPRESSED) { yield { type: GeminiEventType.ChatCompressed, value: compressed }; @@ -1158,6 +1158,7 @@ export class GeminiClient { async tryCompressChat( prompt_id: string, force: boolean = false, + abortSignal?: AbortSignal, ): Promise { // If the model is 'auto', we will use a placeholder model to check. // Compression occurs before we choose a model, so calling `count_tokens` @@ -1171,6 +1172,7 @@ export class GeminiClient { model, this.config, this.hasFailedCompressionAttempt, + abortSignal, ); if ( From 1b052df52f768889204a2d62f5f75c6dadce5632 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Wed, 25 Mar 2026 17:54:45 +0000 Subject: [PATCH 10/49] feat(core): implement Windows sandbox dynamic expansion Phase 1 and 2.1 (#23691) --- packages/core/src/config/config.ts | 10 +- .../core/src/policy/policy-engine.test.ts | 40 +- packages/core/src/policy/policy-engine.ts | 46 +- packages/core/src/policy/types.ts | 9 +- .../src/sandbox/linux/LinuxSandboxManager.ts | 13 + .../sandbox/macos/MacOsSandboxManager.test.ts | 2 +- .../src/sandbox/macos/MacOsSandboxManager.ts | 76 +-- .../core/src/sandbox/macos/commandSafety.ts | 74 ++- .../windows/WindowsSandboxManager.test.ts | 579 ++++++++++++------ .../sandbox/windows/WindowsSandboxManager.ts | 121 +++- .../src/sandbox/windows/commandSafety.test.ts | 50 ++ .../core/src/sandbox/windows/commandSafety.ts | 148 +++++ .../core/src/services/sandboxManager.test.ts | 416 +++++++------ packages/core/src/services/sandboxManager.ts | 38 ++ .../src/services/sandboxManagerFactory.ts | 27 +- .../sandboxedFileSystemService.test.ts | 8 + .../services/shellExecutionService.test.ts | 2 + packages/core/src/utils/shell-utils.ts | 37 +- 18 files changed, 1168 insertions(+), 528 deletions(-) create mode 100644 packages/core/src/sandbox/windows/commandSafety.test.ts create mode 100644 packages/core/src/sandbox/windows/commandSafety.ts diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 795df747cb..a7af5387d6 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1197,10 +1197,7 @@ export class Config implements McpContext, AgentLoopContext { ...params.policyEngineConfig, approvalMode: engineApprovalMode, disableAlwaysAllow: this.disableAlwaysAllow, - toolSandboxEnabled: this.getSandboxEnabled(), - sandboxApprovedTools: - this.sandboxPolicyManager?.getModeConfig(engineApprovalMode) - ?.approvedTools ?? [], + sandboxManager: this._sandboxManager, }, checkerRunner, ); @@ -2392,10 +2389,7 @@ export class Config implements McpContext, AgentLoopContext { ); } - this.policyEngine.setApprovalMode( - mode, - this.sandboxPolicyManager?.getModeConfig(mode)?.approvedTools ?? [], - ); + this.policyEngine.setApprovalMode(mode); this.refreshSandboxManager(); const isPlanModeTransition = diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts index 805e4cef70..137ca76aa1 100644 --- a/packages/core/src/policy/policy-engine.test.ts +++ b/packages/core/src/policy/policy-engine.test.ts @@ -22,6 +22,11 @@ import { SafetyCheckDecision } from '../safety/protocol.js'; import type { CheckerRunner } from '../safety/checker-runner.js'; import { initializeShellParsers } from '../utils/shell-utils.js'; import { buildArgsPatterns } from './utils.js'; +import { + NoopSandboxManager, + LocalSandboxManager, + type SandboxManager, +} from '../services/sandboxManager.js'; // Mock shell-utils to ensure consistent behavior across platforms (especially Windows CI) // We want to test PolicyEngine logic, not the shell parser's ability to parse commands @@ -96,7 +101,10 @@ describe('PolicyEngine', () => { runChecker: vi.fn(), } as unknown as CheckerRunner; engine = new PolicyEngine( - { approvalMode: ApprovalMode.DEFAULT }, + { + approvalMode: ApprovalMode.DEFAULT, + sandboxManager: new NoopSandboxManager(), + }, mockCheckerRunner, ); }); @@ -332,7 +340,7 @@ describe('PolicyEngine', () => { engine = new PolicyEngine({ rules, approvalMode: ApprovalMode.AUTO_EDIT, - toolSandboxEnabled: true, + sandboxManager: new LocalSandboxManager(), }); expect((await engine.check({ name: 'edit' }, undefined)).decision).toBe( PolicyDecision.ALLOW, @@ -345,6 +353,29 @@ describe('PolicyEngine', () => { ); }); + it('should respect tools approved by the SandboxManager', async () => { + const mockSandboxManager = { + enabled: true, + prepareCommand: vi.fn(), + isDangerousCommand: vi.fn().mockReturnValue(false), + isKnownSafeCommand: vi + .fn() + .mockImplementation((args) => args[0] === 'npm'), + } as unknown as SandboxManager; + + engine = new PolicyEngine({ + sandboxManager: mockSandboxManager, + defaultDecision: PolicyDecision.ASK_USER, + }); + + const { decision } = await engine.check( + { name: 'run_shell_command', args: { command: 'npm install' } }, + undefined, + ); + + expect(decision).toBe(PolicyDecision.ALLOW); + }); + it('should return ALLOW by default in YOLO mode when no rules match', async () => { engine = new PolicyEngine({ approvalMode: ApprovalMode.YOLO }); @@ -1576,7 +1607,10 @@ describe('PolicyEngine', () => { }, ]; - engine = new PolicyEngine({ rules, toolSandboxEnabled: true }); + engine = new PolicyEngine({ + rules, + sandboxManager: new LocalSandboxManager(), + }); engine.setApprovalMode(ApprovalMode.AUTO_EDIT); const result = await engine.check( diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts index 4a1dc879af..18ab20bb14 100644 --- a/packages/core/src/policy/policy-engine.ts +++ b/packages/core/src/policy/policy-engine.ts @@ -6,9 +6,12 @@ import { type FunctionCall } from '@google/genai'; import { - isDangerousCommand, - isKnownSafeCommand, -} from '../sandbox/macos/commandSafety.js'; + SHELL_TOOL_NAMES, + initializeShellParsers, + splitCommands, + hasRedirection, + extractStringFromParseEntry, +} from '../utils/shell-utils.js'; import { parse as shellParse } from 'shell-quote'; import { PolicyDecision, @@ -24,12 +27,6 @@ import { stableStringify } from './stable-stringify.js'; import { debugLogger } from '../utils/debugLogger.js'; import type { CheckerRunner } from '../safety/checker-runner.js'; import { SafetyCheckDecision } from '../safety/protocol.js'; -import { - SHELL_TOOL_NAMES, - initializeShellParsers, - splitCommands, - hasRedirection, -} from '../utils/shell-utils.js'; import { getToolAliases } from '../tools/tool-names.js'; import { MCP_TOOL_PREFIX, @@ -38,6 +35,10 @@ import { formatMcpToolName, isMcpToolName, } from '../tools/mcp-tool.js'; +import { + type SandboxManager, + NoopSandboxManager, +} from '../services/sandboxManager.js'; function isWildcardPattern(name: string): boolean { return name === '*' || name.includes('*'); @@ -197,8 +198,7 @@ export class PolicyEngine { private readonly disableAlwaysAllow: boolean; private readonly checkerRunner?: CheckerRunner; private approvalMode: ApprovalMode; - private toolSandboxEnabled: boolean; - private sandboxApprovedTools: string[]; + private readonly sandboxManager: SandboxManager; constructor(config: PolicyEngineConfig = {}, checkerRunner?: CheckerRunner) { this.rules = (config.rules ?? []).sort( @@ -249,18 +249,14 @@ export class PolicyEngine { this.disableAlwaysAllow = config.disableAlwaysAllow ?? false; this.checkerRunner = checkerRunner; this.approvalMode = config.approvalMode ?? ApprovalMode.DEFAULT; - this.toolSandboxEnabled = config.toolSandboxEnabled ?? false; - this.sandboxApprovedTools = config.sandboxApprovedTools ?? []; + this.sandboxManager = config.sandboxManager ?? new NoopSandboxManager(); } /** * Update the current approval mode. */ - setApprovalMode(mode: ApprovalMode, sandboxApprovedTools?: string[]): void { + setApprovalMode(mode: ApprovalMode): void { this.approvalMode = mode; - if (sandboxApprovedTools !== undefined) { - this.sandboxApprovedTools = sandboxApprovedTools; - } } /** @@ -285,8 +281,9 @@ export class PolicyEngine { if (!hasRedirection(command)) return false; // Do not downgrade (do not ask user) if sandboxing is enabled and in AUTO_EDIT or YOLO + const sandboxEnabled = !(this.sandboxManager instanceof NoopSandboxManager); if ( - this.toolSandboxEnabled && + sandboxEnabled && (this.approvalMode === ApprovalMode.AUTO_EDIT || this.approvalMode === ApprovalMode.YOLO) ) { @@ -299,7 +296,6 @@ export class PolicyEngine { /** * Check if a shell command is allowed. */ - private async applyShellHeuristics( command: string, decision: PolicyDecision, @@ -307,19 +303,17 @@ export class PolicyEngine { await initializeShellParsers(); try { const parsedObjArgs = shellParse(command); - if (parsedObjArgs.some((arg) => typeof arg === 'object')) return decision; - const parsedArgs = parsedObjArgs.map(String); - if (isDangerousCommand(parsedArgs)) { + const parsedArgs = parsedObjArgs.map(extractStringFromParseEntry); + + if (this.sandboxManager.isDangerousCommand(parsedArgs)) { debugLogger.debug( `[PolicyEngine.check] Command evaluated as dangerous, forcing ASK_USER: ${command}`, ); return PolicyDecision.ASK_USER; } - const isApprovedBySandbox = - this.toolSandboxEnabled && - this.sandboxApprovedTools.includes(parsedArgs[0]); + if ( - (isKnownSafeCommand(parsedArgs) || isApprovedBySandbox) && + this.sandboxManager.isKnownSafeCommand(parsedArgs) && decision === PolicyDecision.ASK_USER ) { debugLogger.debug( diff --git a/packages/core/src/policy/types.ts b/packages/core/src/policy/types.ts index 0fcf682767..2366ec3fe1 100644 --- a/packages/core/src/policy/types.ts +++ b/packages/core/src/policy/types.ts @@ -5,6 +5,7 @@ */ import type { SafetyCheckInput } from '../safety/protocol.js'; +import type { SandboxManager } from '../services/sandboxManager.js'; export enum PolicyDecision { ALLOW = 'allow', @@ -311,13 +312,9 @@ export interface PolicyEngineConfig { approvalMode?: ApprovalMode; /** - * Whether tool sandboxing is enabled. + * The sandbox manager instance. */ - toolSandboxEnabled?: boolean; - /** - * List of tools approved by the sandbox policy for the current mode. - */ - sandboxApprovedTools?: string[]; + sandboxManager?: SandboxManager; } export interface PolicySettings { diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts index 8dd1154846..2b3e8cc7c9 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts @@ -99,12 +99,25 @@ function touch(filePath: string, isDirectory: boolean) { } } +import { + isKnownSafeCommand, + isDangerousCommand, +} from '../macos/commandSafety.js'; + /** * A SandboxManager implementation for Linux that uses Bubblewrap (bwrap). */ export class LinuxSandboxManager implements SandboxManager { constructor(private readonly options: GlobalSandboxOptions) {} + isKnownSafeCommand(args: string[]): boolean { + return isKnownSafeCommand(args); + } + + isDangerousCommand(args: string[]): boolean { + return isDangerousCommand(args); + } + async prepareCommand(req: SandboxRequest): Promise { const sanitizationConfig = getSecureSanitizationConfig( req.policy?.sanitizationConfig, diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts index 7d9bd57cae..0c7e83ecfe 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts @@ -69,7 +69,7 @@ describe('MacOsSandboxManager', () => { allowedPaths: mockAllowedPaths, networkAccess: mockNetworkAccess, forbiddenPaths: undefined, - workspaceWrite: false, + workspaceWrite: true, additionalPermissions: { fileSystem: { read: [], diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts index 10828083a5..c767c18b82 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts @@ -14,23 +14,20 @@ import { import { sanitizeEnvironment, getSecureSanitizationConfig, - type EnvironmentSanitizationConfig, } from '../../services/environmentSanitization.js'; import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js'; import { - getCommandRoots, initializeShellParsers, - splitCommands, - stripShellWrapper, + getCommandName, } from '../../utils/shell-utils.js'; -import { isKnownSafeCommand } from './commandSafety.js'; -import { parse as shellParse } from 'shell-quote'; +import { + isKnownSafeCommand, + isDangerousCommand, + isStrictlyApproved, +} from './commandSafety.js'; import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js'; -import path from 'node:path'; export interface MacOsSandboxOptions extends GlobalSandboxOptions { - /** Optional base sanitization config. */ - sanitizationConfig?: EnvironmentSanitizationConfig; /** The current sandbox mode behavior from config. */ modeConfig?: { readonly?: boolean; @@ -48,52 +45,17 @@ export interface MacOsSandboxOptions extends GlobalSandboxOptions { export class MacOsSandboxManager implements SandboxManager { constructor(private readonly options: MacOsSandboxOptions) {} - private async isStrictlyApproved(req: SandboxRequest): Promise { - const approvedTools = this.options.modeConfig?.approvedTools; - if (!approvedTools || approvedTools.length === 0) { - return false; - } - - await initializeShellParsers(); - - const fullCmd = [req.command, ...req.args].join(' '); - const stripped = stripShellWrapper(fullCmd); - - const roots = getCommandRoots(stripped); - if (roots.length === 0) return false; - - const allRootsApproved = roots.every((root) => - approvedTools.includes(root), - ); - if (allRootsApproved) { + isKnownSafeCommand(args: string[]): boolean { + const toolName = args[0]; + const approvedTools = this.options.modeConfig?.approvedTools ?? []; + if (toolName && approvedTools.includes(toolName)) { return true; } - - const pipelineCommands = splitCommands(stripped); - if (pipelineCommands.length === 0) return false; - - // For safety, every command in the pipeline must be considered safe. - for (const cmdString of pipelineCommands) { - const parsedArgs = shellParse(cmdString).map(String); - if (!isKnownSafeCommand(parsedArgs)) { - return false; - } - } - - return true; + return isKnownSafeCommand(args); } - private async getCommandName(req: SandboxRequest): Promise { - await initializeShellParsers(); - const fullCmd = [req.command, ...req.args].join(' '); - const stripped = stripShellWrapper(fullCmd); - const roots = getCommandRoots(stripped).filter( - (r) => r !== 'shopt' && r !== 'set', - ); - if (roots.length > 0) { - return roots[0]; - } - return path.basename(req.command); + isDangerousCommand(args: string[]): boolean { + return isDangerousCommand(args); } async prepareCommand(req: SandboxRequest): Promise { @@ -122,15 +84,19 @@ export class MacOsSandboxManager implements SandboxManager { // If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes const isApproved = allowOverrides - ? await this.isStrictlyApproved(req) + ? await isStrictlyApproved( + req.command, + req.args, + this.options.modeConfig?.approvedTools, + ) : false; const workspaceWrite = !isReadonlyMode || isApproved; - const networkAccess = + const defaultNetwork = this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false; // Fetch persistent approvals for this command - const commandName = await this.getCommandName(req); + const commandName = await getCommandName(req.command, req.args); const persistentPermissions = allowOverrides ? this.options.policyManager?.getCommandPermissions(commandName) : undefined; @@ -148,7 +114,7 @@ export class MacOsSandboxManager implements SandboxManager { ], }, network: - networkAccess || + defaultNetwork || persistentPermissions?.network || req.policy?.additionalPermissions?.network || false, diff --git a/packages/core/src/sandbox/macos/commandSafety.ts b/packages/core/src/sandbox/macos/commandSafety.ts index a9911932fc..c57f77512b 100644 --- a/packages/core/src/sandbox/macos/commandSafety.ts +++ b/packages/core/src/sandbox/macos/commandSafety.ts @@ -4,6 +4,57 @@ * SPDX-License-Identifier: Apache-2.0 */ import { parse as shellParse } from 'shell-quote'; +import { + extractStringFromParseEntry, + initializeShellParsers, + splitCommands, + stripShellWrapper, +} from '../../utils/shell-utils.js'; + +/** + * Determines if a command is strictly approved for execution on macOS. + * A command is approved if it's composed entirely of tools explicitly listed in `approvedTools` + * OR if it's composed of known safe, read-only POSIX commands. + * + * @param command - The full command string to execute. + * @param args - The arguments for the command. + * @param approvedTools - A list of explicitly approved tool names (e.g., ['npm', 'git']). + * @returns true if the command is strictly approved, false otherwise. + */ +export async function isStrictlyApproved( + command: string, + args: string[], + approvedTools?: string[], +): Promise { + const tools = approvedTools ?? []; + + await initializeShellParsers(); + + const fullCmd = [command, ...args].join(' '); + const stripped = stripShellWrapper(fullCmd); + + const pipelineCommands = splitCommands(stripped); + + // Fallback for simple commands or parsing failures + if (pipelineCommands.length === 0) { + // For simple commands, we check the root command. + // If it's explicitly approved OR it's a known safe POSIX command, we allow it. + return tools.includes(command) || isKnownSafeCommand([command, ...args]); + } + + // Check every segment of the pipeline + return pipelineCommands.every((cmdString) => { + const trimmed = cmdString.trim(); + if (!trimmed) return true; + + const parsedArgs = shellParse(trimmed).map(extractStringFromParseEntry); + if (parsedArgs.length === 0) return true; + + const root = parsedArgs[0]; + // The segment is approved if the root tool is in the allowlist OR if the whole segment is safe. + return tools.includes(root) || isKnownSafeCommand(parsedArgs); + }); +} /** * Checks if a command with its arguments is known to be safe to execute @@ -45,25 +96,18 @@ export function isKnownSafeCommand(args: string[]): boolean { return false; } - const commands = script.split(/&&|\|\||\||;/); + const commands = splitCommands(script); + if (commands.length === 0) return false; - let allSafe = true; - for (const cmd of commands) { + return commands.every((cmd) => { const trimmed = cmd.trim(); - if (!trimmed) continue; + if (!trimmed) return true; - const parsed = shellParse(trimmed).map(String); - if (parsed.length === 0) continue; + const parsed = shellParse(trimmed).map(extractStringFromParseEntry); + if (parsed.length === 0) return true; - if (!isSafeToCallWithExec(parsed)) { - allSafe = false; - break; - } - } - - if (allSafe && commands.length > 0) { - return true; - } + return isSafeToCallWithExec(parsed); + }); } catch { return false; } diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts index 0abd3dd56b..8f9b9d617c 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts @@ -12,10 +12,18 @@ import { WindowsSandboxManager } from './WindowsSandboxManager.js'; import * as sandboxManager from '../../services/sandboxManager.js'; import type { SandboxRequest } from '../../services/sandboxManager.js'; import { spawnAsync } from '../../utils/shell-utils.js'; +import type { SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js'; -vi.mock('../../utils/shell-utils.js', () => ({ - spawnAsync: vi.fn(), -})); +vi.mock('../../utils/shell-utils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + spawnAsync: vi.fn(), + initializeShellParsers: vi.fn(), + isStrictlyApproved: vi.fn().mockResolvedValue(true), + }; +}); describe('WindowsSandboxManager', () => { let manager: WindowsSandboxManager; @@ -27,7 +35,10 @@ describe('WindowsSandboxManager', () => { p.toString(), ); testCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-')); - manager = new WindowsSandboxManager({ workspace: testCwd }); + manager = new WindowsSandboxManager({ + workspace: testCwd, + modeConfig: { readonly: false, allowOverrides: true }, + }); }); afterEach(() => { @@ -35,240 +46,406 @@ describe('WindowsSandboxManager', () => { fs.rmSync(testCwd, { recursive: true, force: true }); }); - describe('prepareCommand', () => { - it('should correctly format the base command and args', async () => { - const req: SandboxRequest = { - command: 'whoami', - args: ['/groups'], - cwd: testCwd, - env: { TEST_VAR: 'test_value' }, - policy: { - networkAccess: false, + it('should prepare a GeminiSandbox.exe command', async () => { + const req: SandboxRequest = { + command: 'whoami', + args: ['/groups'], + cwd: testCwd, + env: { TEST_VAR: 'test_value' }, + policy: { + networkAccess: false, + }, + }; + + const result = await manager.prepareCommand(req); + + expect(result.program).toContain('GeminiSandbox.exe'); + expect(result.args).toEqual(['0', testCwd, 'whoami', '/groups']); + }); + + it('should handle networkAccess from config', async () => { + const req: SandboxRequest = { + command: 'whoami', + args: [], + cwd: testCwd, + env: {}, + policy: { + networkAccess: true, + }, + }; + + const result = await manager.prepareCommand(req); + expect(result.args[0]).toBe('1'); + }); + + it('should handle network access from additionalPermissions', async () => { + const req: SandboxRequest = { + command: 'whoami', + args: [], + cwd: testCwd, + env: {}, + policy: { + additionalPermissions: { + network: true, }, - }; + }, + }; - const result = await manager.prepareCommand(req); + const result = await manager.prepareCommand(req); + expect(result.args[0]).toBe('1'); + }); - expect(result.program).toContain('GeminiSandbox.exe'); - expect(result.args).toEqual(['0', testCwd, 'whoami', '/groups']); + it('should reject network access in Plan mode', async () => { + const planManager = new WindowsSandboxManager({ + workspace: testCwd, + modeConfig: { readonly: true, allowOverrides: false }, + }); + const req: SandboxRequest = { + command: 'curl', + args: ['google.com'], + cwd: testCwd, + env: {}, + policy: { + additionalPermissions: { network: true }, + }, + }; + + await expect(planManager.prepareCommand(req)).rejects.toThrow( + 'Sandbox request rejected: Cannot override readonly/network restrictions in Plan mode.', + ); + }); + + it('should handle persistent permissions from policyManager', async () => { + const persistentPath = path.resolve('/persistent/path'); + const mockPolicyManager = { + getCommandPermissions: vi.fn().mockReturnValue({ + fileSystem: { write: [persistentPath] }, + network: true, + }), + } as unknown as SandboxPolicyManager; + + const managerWithPolicy = new WindowsSandboxManager({ + workspace: testCwd, + modeConfig: { allowOverrides: true, network: false }, + policyManager: mockPolicyManager, }); - it('should correctly pass through the cwd to the resulting command', async () => { - const req: SandboxRequest = { - command: 'whoami', - args: [], - cwd: '/different/cwd', - env: {}, - }; + const req: SandboxRequest = { + command: 'test-cmd', + args: [], + cwd: testCwd, + env: {}, + }; - const result = await manager.prepareCommand(req); + const result = await managerWithPolicy.prepareCommand(req); + expect(result.args[0]).toBe('1'); // Network allowed by persistent policy - expect(result.cwd).toBe('/different/cwd'); - }); + const icaclsArgs = vi + .mocked(spawnAsync) + .mock.calls.filter((c) => c[0] === 'icacls') + .map((c) => c[1]); - it('should apply environment sanitization via the default mechanisms', async () => { + expect(icaclsArgs).toContainEqual([ + persistentPath, + '/setintegritylevel', + 'Low', + ]); + }); + + it('should sanitize environment variables', async () => { + const req: SandboxRequest = { + command: 'test', + args: [], + cwd: testCwd, + env: { + API_KEY: 'secret', + PATH: '/usr/bin', + }, + policy: { + sanitizationConfig: { + allowedEnvironmentVariables: ['PATH'], + blockedEnvironmentVariables: ['API_KEY'], + enableEnvironmentVariableRedaction: true, + }, + }, + }; + + const result = await manager.prepareCommand(req); + expect(result.env['PATH']).toBe('/usr/bin'); + expect(result.env['API_KEY']).toBeUndefined(); + }); + + it('should ensure governance files exist', async () => { + const req: SandboxRequest = { + command: 'test', + args: [], + cwd: testCwd, + env: {}, + }; + + await manager.prepareCommand(req); + + expect(fs.existsSync(path.join(testCwd, '.gitignore'))).toBe(true); + expect(fs.existsSync(path.join(testCwd, '.geminiignore'))).toBe(true); + expect(fs.existsSync(path.join(testCwd, '.git'))).toBe(true); + expect(fs.lstatSync(path.join(testCwd, '.git')).isDirectory()).toBe(true); + }); + + it('should grant Low Integrity access to the workspace and allowed paths', async () => { + const allowedPath = path.join(os.tmpdir(), 'gemini-cli-test-allowed'); + if (!fs.existsSync(allowedPath)) { + fs.mkdirSync(allowedPath); + } + try { const req: SandboxRequest = { command: 'test', args: [], cwd: testCwd, - env: { - API_KEY: 'secret', - PATH: '/usr/bin', - }, + env: {}, policy: { - sanitizationConfig: { - allowedEnvironmentVariables: ['PATH'], - blockedEnvironmentVariables: ['API_KEY'], - enableEnvironmentVariableRedaction: true, - }, + allowedPaths: [allowedPath], }, }; - const result = await manager.prepareCommand(req); - expect(result.env['PATH']).toBe('/usr/bin'); - expect(result.env['API_KEY']).toBeUndefined(); - }); + await manager.prepareCommand(req); - it('should allow network when networkAccess is true', async () => { + const icaclsArgs = vi + .mocked(spawnAsync) + .mock.calls.filter((c) => c[0] === 'icacls') + .map((c) => c[1]); + + expect(icaclsArgs).toContainEqual([ + path.resolve(testCwd), + '/setintegritylevel', + 'Low', + ]); + + expect(icaclsArgs).toContainEqual([ + path.resolve(allowedPath), + '/setintegritylevel', + 'Low', + ]); + } finally { + fs.rmSync(allowedPath, { recursive: true, force: true }); + } + }); + + it('should grant Low Integrity access to additional write paths', async () => { + const extraWritePath = path.join( + os.tmpdir(), + 'gemini-cli-test-extra-write', + ); + if (!fs.existsSync(extraWritePath)) { + fs.mkdirSync(extraWritePath); + } + try { const req: SandboxRequest = { - command: 'whoami', + command: 'test', args: [], cwd: testCwd, env: {}, policy: { - networkAccess: true, + additionalPermissions: { + fileSystem: { + write: [extraWritePath], + }, + }, }, }; - const result = await manager.prepareCommand(req); - expect(result.args[0]).toBe('1'); - }); + await manager.prepareCommand(req); - describe('governance files', () => { - it('should ensure governance files exist', async () => { - const req: SandboxRequest = { - command: 'test', - args: [], - cwd: testCwd, - env: {}, - }; + const icaclsArgs = vi + .mocked(spawnAsync) + .mock.calls.filter((c) => c[0] === 'icacls') + .map((c) => c[1]); - await manager.prepareCommand(req); + expect(icaclsArgs).toContainEqual([ + path.resolve(extraWritePath), + '/setintegritylevel', + 'Low', + ]); + } finally { + fs.rmSync(extraWritePath, { recursive: true, force: true }); + } + }); - expect(fs.existsSync(path.join(testCwd, '.gitignore'))).toBe(true); - expect(fs.existsSync(path.join(testCwd, '.geminiignore'))).toBe(true); - expect(fs.existsSync(path.join(testCwd, '.git'))).toBe(true); - expect(fs.lstatSync(path.join(testCwd, '.git')).isDirectory()).toBe( - true, - ); - }); - }); - - describe('allowedPaths', () => { - it('should parameterize allowed paths and normalize them', async () => { - const allowedPath = path.join(os.tmpdir(), 'gemini-cli-test-allowed'); - if (!fs.existsSync(allowedPath)) { - fs.mkdirSync(allowedPath); - } - try { - const req: SandboxRequest = { - command: 'test', - args: [], - cwd: testCwd, - env: {}, - policy: { - allowedPaths: [allowedPath], + it.runIf(process.platform === 'win32')( + 'should reject UNC paths in grantLowIntegrityAccess', + async () => { + const uncPath = '\\\\attacker\\share\\malicious.txt'; + const req: SandboxRequest = { + command: 'test', + args: [], + cwd: testCwd, + env: {}, + policy: { + additionalPermissions: { + fileSystem: { + write: [uncPath], }, - }; - - await manager.prepareCommand(req); - - expect(spawnAsync).toHaveBeenCalledWith('icacls', [ - path.resolve(testCwd), - '/setintegritylevel', - 'Low', - ]); - - expect(spawnAsync).toHaveBeenCalledWith('icacls', [ - path.resolve(allowedPath), - '/setintegritylevel', - 'Low', - ]); - } finally { - fs.rmSync(allowedPath, { recursive: true, force: true }); - } - }); - }); - - describe('forbiddenPaths', () => { - it('should parameterize forbidden paths and explicitly deny them', async () => { - const forbiddenPath = path.join( - os.tmpdir(), - 'gemini-cli-test-forbidden', - ); - if (!fs.existsSync(forbiddenPath)) { - fs.mkdirSync(forbiddenPath); - } - try { - const req: SandboxRequest = { - command: 'test', - args: [], - cwd: testCwd, - env: {}, - policy: { - forbiddenPaths: [forbiddenPath], - }, - }; - - await manager.prepareCommand(req); - - expect(spawnAsync).toHaveBeenCalledWith('icacls', [ - path.resolve(forbiddenPath), - '/deny', - '*S-1-16-4096:(OI)(CI)(F)', - ]); - } finally { - fs.rmSync(forbiddenPath, { recursive: true, force: true }); - } - }); - - it('explicitly denies non-existent forbidden paths to prevent creation', async () => { - const missingPath = path.join( - os.tmpdir(), - 'gemini-cli-test-missing', - 'does-not-exist.txt', - ); - - // Ensure it definitely doesn't exist - if (fs.existsSync(missingPath)) { - fs.rmSync(missingPath, { recursive: true, force: true }); - } - - const req: SandboxRequest = { - command: 'test', - args: [], - cwd: testCwd, - env: {}, - policy: { - forbiddenPaths: [missingPath], }, - }; + }, + }; - await manager.prepareCommand(req); + await manager.prepareCommand(req); - // Should NOT have called icacls to deny the missing path - expect(spawnAsync).not.toHaveBeenCalledWith('icacls', [ - path.resolve(missingPath), - '/deny', - '*S-1-16-4096:(OI)(CI)(F)', - ]); - }); + const icaclsArgs = vi + .mocked(spawnAsync) + .mock.calls.filter((c) => c[0] === 'icacls') + .map((c) => c[1]); - it('should override allowed paths if a path is also in forbidden paths', async () => { - const conflictPath = path.join(os.tmpdir(), 'gemini-cli-test-conflict'); - if (!fs.existsSync(conflictPath)) { - fs.mkdirSync(conflictPath); - } - try { - const req: SandboxRequest = { - command: 'test', - args: [], - cwd: testCwd, - env: {}, - policy: { - allowedPaths: [conflictPath], - forbiddenPaths: [conflictPath], + expect(icaclsArgs).not.toContainEqual([ + uncPath, + '/setintegritylevel', + 'Low', + ]); + }, + ); + + it.runIf(process.platform === 'win32')( + 'should allow extended-length and local device paths', + async () => { + const longPath = '\\\\?\\C:\\very\\long\\path'; + const devicePath = '\\\\.\\PhysicalDrive0'; + + const req: SandboxRequest = { + command: 'test', + args: [], + cwd: testCwd, + env: {}, + policy: { + additionalPermissions: { + fileSystem: { + write: [longPath, devicePath], }, - }; + }, + }, + }; - await manager.prepareCommand(req); + await manager.prepareCommand(req); - const spawnMock = vi.mocked(spawnAsync); - const allowCallIndex = spawnMock.mock.calls.findIndex( - (call) => - call[1] && - call[1].includes('/setintegritylevel') && - call[0] === 'icacls' && - call[1][0] === path.resolve(conflictPath), - ); - const denyCallIndex = spawnMock.mock.calls.findIndex( - (call) => - call[1] && - call[1].includes('/deny') && - call[0] === 'icacls' && - call[1][0] === path.resolve(conflictPath), - ); + const icaclsArgs = vi + .mocked(spawnAsync) + .mock.calls.filter((c) => c[0] === 'icacls') + .map((c) => c[1]); - // Both should have been called - expect(allowCallIndex).toBeGreaterThan(-1); - expect(denyCallIndex).toBeGreaterThan(-1); + expect(icaclsArgs).toContainEqual([ + longPath, + '/setintegritylevel', + 'Low', + ]); + expect(icaclsArgs).toContainEqual([ + devicePath, + '/setintegritylevel', + 'Low', + ]); + }, + ); - // Verify order: explicitly denying must happen after the explicit allow - expect(allowCallIndex).toBeLessThan(denyCallIndex); - } finally { - fs.rmSync(conflictPath, { recursive: true, force: true }); - } - }); - }); + it('skips denying access to non-existent forbidden paths to prevent icacls failure', async () => { + const missingPath = path.join( + os.tmpdir(), + 'gemini-cli-test-missing', + 'does-not-exist.txt', + ); + + // Ensure it definitely doesn't exist + if (fs.existsSync(missingPath)) { + fs.rmSync(missingPath, { recursive: true, force: true }); + } + + const req: SandboxRequest = { + command: 'test', + args: [], + cwd: testCwd, + env: {}, + policy: { + forbiddenPaths: [missingPath], + }, + }; + + await manager.prepareCommand(req); + + // Should NOT have called icacls to deny the missing path + expect(spawnAsync).not.toHaveBeenCalledWith('icacls', [ + path.resolve(missingPath), + '/deny', + '*S-1-16-4096:(OI)(CI)(F)', + ]); + }); + + it('should deny Low Integrity access to forbidden paths', async () => { + const forbiddenPath = path.join(os.tmpdir(), 'gemini-cli-test-forbidden'); + if (!fs.existsSync(forbiddenPath)) { + fs.mkdirSync(forbiddenPath); + } + try { + const req: SandboxRequest = { + command: 'test', + args: [], + cwd: testCwd, + env: {}, + policy: { + forbiddenPaths: [forbiddenPath], + }, + }; + + await manager.prepareCommand(req); + + expect(spawnAsync).toHaveBeenCalledWith('icacls', [ + path.resolve(forbiddenPath), + '/deny', + '*S-1-16-4096:(OI)(CI)(F)', + ]); + } finally { + fs.rmSync(forbiddenPath, { recursive: true, force: true }); + } + }); + + it('should override allowed paths if a path is also in forbidden paths', async () => { + const conflictPath = path.join(os.tmpdir(), 'gemini-cli-test-conflict'); + if (!fs.existsSync(conflictPath)) { + fs.mkdirSync(conflictPath); + } + try { + const req: SandboxRequest = { + command: 'test', + args: [], + cwd: testCwd, + env: {}, + policy: { + allowedPaths: [conflictPath], + forbiddenPaths: [conflictPath], + }, + }; + + await manager.prepareCommand(req); + + const spawnMock = vi.mocked(spawnAsync); + const allowCallIndex = spawnMock.mock.calls.findIndex( + (call) => + call[1] && + call[1].includes('/setintegritylevel') && + call[0] === 'icacls' && + call[1][0] === path.resolve(conflictPath), + ); + const denyCallIndex = spawnMock.mock.calls.findIndex( + (call) => + call[1] && + call[1].includes('/deny') && + call[0] === 'icacls' && + call[1][0] === path.resolve(conflictPath), + ); + + // Both should have been called + expect(allowCallIndex).toBeGreaterThan(-1); + expect(denyCallIndex).toBeGreaterThan(-1); + + // Verify order: explicitly denying must happen after the explicit allow + expect(allowCallIndex).toBeLessThan(denyCallIndex); + } finally { + fs.rmSync(conflictPath, { recursive: true, force: true }); + } }); }); diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts index 0a1bc2a95f..0a5d08637c 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts @@ -16,18 +16,37 @@ import { type GlobalSandboxOptions, sanitizePaths, tryRealpath, + type SandboxPermissions, } from '../../services/sandboxManager.js'; import { sanitizeEnvironment, getSecureSanitizationConfig, } from '../../services/environmentSanitization.js'; import { debugLogger } from '../../utils/debugLogger.js'; -import { spawnAsync } from '../../utils/shell-utils.js'; +import { spawnAsync, getCommandName } from '../../utils/shell-utils.js'; import { isNodeError } from '../../utils/errors.js'; +import { + isKnownSafeCommand, + isDangerousCommand, + isStrictlyApproved, +} from './commandSafety.js'; +import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +export interface WindowsSandboxOptions extends GlobalSandboxOptions { + /** The current sandbox mode behavior from config. */ + modeConfig?: { + readonly?: boolean; + network?: boolean; + approvedTools?: string[]; + allowOverrides?: boolean; + }; + /** The policy manager for persistent approvals. */ + policyManager?: SandboxPolicyManager; +} + /** * A SandboxManager implementation for Windows that uses Restricted Tokens, * Job Objects, and Low Integrity levels for process isolation. @@ -39,10 +58,23 @@ export class WindowsSandboxManager implements SandboxManager { private readonly allowedCache = new Set(); private readonly deniedCache = new Set(); - constructor(private readonly options: GlobalSandboxOptions) { + constructor(private readonly options: WindowsSandboxOptions) { this.helperPath = path.resolve(__dirname, 'GeminiSandbox.exe'); } + isKnownSafeCommand(args: string[]): boolean { + const toolName = args[0]?.toLowerCase(); + const approvedTools = this.options.modeConfig?.approvedTools ?? []; + if (toolName && approvedTools.some((t) => t.toLowerCase() === toolName)) { + return true; + } + return isKnownSafeCommand(args); + } + + isDangerousCommand(args: string[]): boolean { + return isDangerousCommand(args); + } + /** * Ensures a file or directory exists. */ @@ -178,9 +210,60 @@ export class WindowsSandboxManager implements SandboxManager { const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig); + const isReadonlyMode = this.options.modeConfig?.readonly ?? true; + const allowOverrides = this.options.modeConfig?.allowOverrides ?? true; + + // Reject override attempts in plan mode + if (!allowOverrides && req.policy?.additionalPermissions) { + const perms = req.policy.additionalPermissions; + if ( + perms.network || + (perms.fileSystem?.write && perms.fileSystem.write.length > 0) + ) { + throw new Error( + 'Sandbox request rejected: Cannot override readonly/network restrictions in Plan mode.', + ); + } + } + + // Fetch persistent approvals for this command + const commandName = await getCommandName(req.command, req.args); + const persistentPermissions = allowOverrides + ? this.options.policyManager?.getCommandPermissions(commandName) + : undefined; + + // Merge all permissions + const mergedAdditional: SandboxPermissions = { + fileSystem: { + read: [ + ...(persistentPermissions?.fileSystem?.read ?? []), + ...(req.policy?.additionalPermissions?.fileSystem?.read ?? []), + ], + write: [ + ...(persistentPermissions?.fileSystem?.write ?? []), + ...(req.policy?.additionalPermissions?.fileSystem?.write ?? []), + ], + }, + network: + persistentPermissions?.network || + req.policy?.additionalPermissions?.network || + false, + }; + // 1. Handle filesystem permissions for Low Integrity // Grant "Low Mandatory Level" write access to the workspace. - await this.grantLowIntegrityAccess(this.options.workspace); + // If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes + const isApproved = allowOverrides + ? await isStrictlyApproved( + req.command, + req.args, + this.options.modeConfig?.approvedTools, + ) + : false; + + if (!isReadonlyMode || isApproved) { + await this.grantLowIntegrityAccess(this.options.workspace); + } // Grant "Low Mandatory Level" read access to allowedPaths. const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || []; @@ -188,6 +271,13 @@ export class WindowsSandboxManager implements SandboxManager { await this.grantLowIntegrityAccess(allowedPath); } + // Grant "Low Mandatory Level" write access to additional permissions write paths. + const additionalWritePaths = + sanitizePaths(mergedAdditional.fileSystem?.write) || []; + for (const writePath of additionalWritePaths) { + await this.grantLowIntegrityAccess(writePath); + } + // Denies access to forbiddenPaths for Low Integrity processes. const forbiddenPaths = sanitizePaths(req.policy?.forbiddenPaths) || []; for (const forbiddenPath of forbiddenPaths) { @@ -219,13 +309,12 @@ export class WindowsSandboxManager implements SandboxManager { // GeminiSandbox.exe [args...] const program = this.helperPath; + const defaultNetwork = + this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false; + const networkAccess = defaultNetwork || mergedAdditional.network; + // If the command starts with __, it's an internal command for the sandbox helper itself. - const args = [ - req.policy?.networkAccess ? '1' : '0', - req.cwd, - req.command, - ...req.args, - ]; + const args = [networkAccess ? '1' : '0', req.cwd, req.command, ...req.args]; return { program, @@ -248,6 +337,20 @@ export class WindowsSandboxManager implements SandboxManager { return; } + // Explicitly reject UNC paths to prevent credential theft/SSRF, + // but allow local extended-length and device paths. + if ( + resolvedPath.startsWith('\\\\') && + !resolvedPath.startsWith('\\\\?\\') && + !resolvedPath.startsWith('\\\\.\\') + ) { + debugLogger.log( + 'WindowsSandboxManager: Rejecting UNC path for Low Integrity grant:', + resolvedPath, + ); + return; + } + // Never modify integrity levels for system directories const systemRoot = process.env['SystemRoot'] || 'C:\\Windows'; const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files'; diff --git a/packages/core/src/sandbox/windows/commandSafety.test.ts b/packages/core/src/sandbox/windows/commandSafety.test.ts new file mode 100644 index 0000000000..82077b2690 --- /dev/null +++ b/packages/core/src/sandbox/windows/commandSafety.test.ts @@ -0,0 +1,50 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { isKnownSafeCommand, isDangerousCommand } from './commandSafety.js'; + +describe('Windows commandSafety', () => { + describe('isKnownSafeCommand', () => { + it('should identify known safe commands', () => { + expect(isKnownSafeCommand(['dir'])).toBe(true); + expect(isKnownSafeCommand(['echo', 'hello'])).toBe(true); + expect(isKnownSafeCommand(['whoami'])).toBe(true); + }); + + it('should strip .exe extension for safe commands', () => { + expect(isKnownSafeCommand(['dir.exe'])).toBe(true); + expect(isKnownSafeCommand(['ECHO.EXE', 'hello'])).toBe(true); + expect(isKnownSafeCommand(['WHOAMI.exe'])).toBe(true); + }); + + it('should reject unknown commands', () => { + expect(isKnownSafeCommand(['unknown'])).toBe(false); + expect(isKnownSafeCommand(['npm', 'install'])).toBe(false); + }); + }); + + describe('isDangerousCommand', () => { + it('should identify dangerous commands', () => { + expect(isDangerousCommand(['del', 'file.txt'])).toBe(true); + expect(isDangerousCommand(['powershell', '-Command', 'echo'])).toBe(true); + expect(isDangerousCommand(['cmd', '/c', 'dir'])).toBe(true); + }); + + it('should strip .exe extension for dangerous commands', () => { + expect(isDangerousCommand(['del.exe', 'file.txt'])).toBe(true); + expect(isDangerousCommand(['POWERSHELL.EXE', '-Command', 'echo'])).toBe( + true, + ); + expect(isDangerousCommand(['cmd.exe', '/c', 'dir'])).toBe(true); + }); + + it('should not flag safe commands as dangerous', () => { + expect(isDangerousCommand(['dir'])).toBe(false); + expect(isDangerousCommand(['echo', 'hello'])).toBe(false); + }); + }); +}); diff --git a/packages/core/src/sandbox/windows/commandSafety.ts b/packages/core/src/sandbox/windows/commandSafety.ts new file mode 100644 index 0000000000..bff2976e62 --- /dev/null +++ b/packages/core/src/sandbox/windows/commandSafety.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { parse as shellParse } from 'shell-quote'; +import { + extractStringFromParseEntry, + initializeShellParsers, + splitCommands, + stripShellWrapper, +} from '../../utils/shell-utils.js'; + +/** + * Determines if a command is strictly approved for execution on Windows. + * A command is approved if it's composed entirely of tools explicitly listed in `approvedTools` + * OR if it's composed of known safe, read-only Windows commands. + * + * @param command - The full command string to execute. + * @param args - The arguments for the command. + * @param approvedTools - A list of explicitly approved tool names (e.g., ['npm', 'git']). + * @returns true if the command is strictly approved, false otherwise. + */ +export async function isStrictlyApproved( + command: string, + args: string[], + approvedTools?: string[], +): Promise { + const tools = approvedTools ?? []; + + await initializeShellParsers(); + + const fullCmd = [command, ...args].join(' '); + const stripped = stripShellWrapper(fullCmd); + + const pipelineCommands = splitCommands(stripped); + + // Fallback for simple commands or parsing failures + if (pipelineCommands.length === 0) { + return tools.includes(command) || isKnownSafeCommand([command, ...args]); + } + + // Check every segment of the pipeline + return pipelineCommands.every((cmdString) => { + const trimmed = cmdString.trim(); + if (!trimmed) return true; + + const parsedArgs = shellParse(trimmed).map(extractStringFromParseEntry); + if (parsedArgs.length === 0) return true; + + let root = parsedArgs[0].toLowerCase(); + if (root.endsWith('.exe')) { + root = root.slice(0, -4); + } + // The segment is approved if the root tool is in the allowlist OR if the whole segment is safe. + return ( + tools.some((t) => t.toLowerCase() === root) || + isKnownSafeCommand(parsedArgs) + ); + }); +} + +/** + * Checks if a Windows command is known to be safe (read-only). + */ +export function isKnownSafeCommand(args: string[]): boolean { + if (!args || args.length === 0) return false; + let cmd = args[0].toLowerCase(); + if (cmd.endsWith('.exe')) { + cmd = cmd.slice(0, -4); + } + + // Native Windows/PowerShell safe commands + const safeCommands = new Set([ + 'dir', + 'type', + 'echo', + 'cd', + 'pwd', + 'whoami', + 'hostname', + 'ver', + 'vol', + 'systeminfo', + 'attrib', + 'findstr', + 'where', + 'sort', + 'more', + 'get-childitem', + 'get-content', + 'get-location', + 'get-help', + 'get-process', + 'get-service', + 'get-eventlog', + 'select-string', + ]); + + if (safeCommands.has(cmd)) { + return true; + } + + // We allow git on Windows if it's read-only, using the same logic as POSIX + if (cmd === 'git') { + // For simplicity in this branch, we'll allow standard git read operations + // In a full implementation, we'd port the sub-command validation too. + const sub = args[1]?.toLowerCase(); + return ['status', 'log', 'diff', 'show', 'branch'].includes(sub); + } + + return false; +} + +/** + * Checks if a Windows command is explicitly dangerous. + */ +export function isDangerousCommand(args: string[]): boolean { + if (!args || args.length === 0) return false; + let cmd = args[0].toLowerCase(); + if (cmd.endsWith('.exe')) { + cmd = cmd.slice(0, -4); + } + + const dangerous = new Set([ + 'del', + 'erase', + 'rd', + 'rmdir', + 'net', + 'reg', + 'sc', + 'format', + 'mklink', + 'takeown', + 'icacls', + 'powershell', // prevent shell escapes + 'pwsh', + 'cmd', + 'remove-item', + 'stop-process', + 'stop-service', + 'set-item', + 'new-item', + ]); + + return dangerous.has(cmd); +} diff --git a/packages/core/src/services/sandboxManager.test.ts b/packages/core/src/services/sandboxManager.test.ts index 411b49636b..1f3cfa089e 100644 --- a/packages/core/src/services/sandboxManager.test.ts +++ b/packages/core/src/services/sandboxManager.test.ts @@ -3,13 +3,13 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ - import os from 'node:os'; import path from 'node:path'; import fs from 'node:fs/promises'; -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest'; import { NoopSandboxManager, + LocalSandboxManager, sanitizePaths, tryRealpath, } from './sandboxManager.js'; @@ -18,225 +18,265 @@ import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js'; import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js'; import { WindowsSandboxManager } from '../sandbox/windows/WindowsSandboxManager.js'; -describe('sanitizePaths', () => { - it('should return undefined if no paths are provided', () => { - expect(sanitizePaths(undefined)).toBeUndefined(); - }); +describe('SandboxManager', () => { + afterEach(() => vi.restoreAllMocks()); - it('should deduplicate paths and return them', () => { - const paths = ['/workspace/foo', '/workspace/bar', '/workspace/foo']; - expect(sanitizePaths(paths)).toEqual(['/workspace/foo', '/workspace/bar']); - }); - - it('should throw an error if a path is not absolute', () => { - const paths = ['/workspace/foo', 'relative/path']; - expect(() => sanitizePaths(paths)).toThrow( - 'Sandbox path must be absolute: relative/path', - ); - }); -}); - -describe('tryRealpath', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('should return the realpath if the file exists', async () => { - vi.spyOn(fs, 'realpath').mockResolvedValue('/real/path/to/file.txt'); - const result = await tryRealpath('/some/symlink/to/file.txt'); - expect(result).toBe('/real/path/to/file.txt'); - expect(fs.realpath).toHaveBeenCalledWith('/some/symlink/to/file.txt'); - }); - - it('should fallback to parent directory if file does not exist (ENOENT)', async () => { - vi.spyOn(fs, 'realpath').mockImplementation(async (p) => { - if (p === '/workspace/nonexistent.txt') { - throw Object.assign(new Error('ENOENT: no such file or directory'), { - code: 'ENOENT', - }); - } - if (p === '/workspace') { - return '/real/workspace'; - } - throw new Error(`Unexpected path: ${p}`); + describe('sanitizePaths', () => { + it('should return undefined if no paths are provided', () => { + expect(sanitizePaths(undefined)).toBeUndefined(); }); - const result = await tryRealpath('/workspace/nonexistent.txt'); - - // It should combine the real path of the parent with the original basename - expect(result).toBe(path.join('/real/workspace', 'nonexistent.txt')); - }); - - it('should recursively fallback up the directory tree on multiple ENOENT errors', async () => { - vi.spyOn(fs, 'realpath').mockImplementation(async (p) => { - if (p === '/workspace/missing_dir/missing_file.txt') { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - } - if (p === '/workspace/missing_dir') { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - } - if (p === '/workspace') { - return '/real/workspace'; - } - throw new Error(`Unexpected path: ${p}`); + it('should deduplicate paths and return them', () => { + const paths = ['/workspace/foo', '/workspace/bar', '/workspace/foo']; + expect(sanitizePaths(paths)).toEqual([ + '/workspace/foo', + '/workspace/bar', + ]); }); - const result = await tryRealpath('/workspace/missing_dir/missing_file.txt'); - - // It should resolve '/workspace' to '/real/workspace' and append the missing parts - expect(result).toBe( - path.join('/real/workspace', 'missing_dir', 'missing_file.txt'), - ); + it('should throw an error if a path is not absolute', () => { + const paths = ['/workspace/foo', 'relative/path']; + expect(() => sanitizePaths(paths)).toThrow( + 'Sandbox path must be absolute: relative/path', + ); + }); }); - it('should return the path unchanged if it reaches the root directory and it still does not exist', async () => { - const rootPath = path.resolve('/'); - vi.spyOn(fs, 'realpath').mockImplementation(async () => { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + describe('tryRealpath', () => { + beforeEach(() => { + vi.clearAllMocks(); }); - const result = await tryRealpath(rootPath); - expect(result).toBe(rootPath); - }); + it('should return the realpath if the file exists', async () => { + vi.spyOn(fs, 'realpath').mockResolvedValue('/real/path/to/file.txt'); + const result = await tryRealpath('/some/symlink/to/file.txt'); + expect(result).toBe('/real/path/to/file.txt'); + expect(fs.realpath).toHaveBeenCalledWith('/some/symlink/to/file.txt'); + }); - it('should throw an error if realpath fails with a non-ENOENT error (e.g. EACCES)', async () => { - vi.spyOn(fs, 'realpath').mockImplementation(async () => { - throw Object.assign(new Error('EACCES: permission denied'), { - code: 'EACCES', + it('should fallback to parent directory if file does not exist (ENOENT)', async () => { + vi.spyOn(fs, 'realpath').mockImplementation(async (p) => { + if (p === '/workspace/nonexistent.txt') { + throw Object.assign(new Error('ENOENT: no such file or directory'), { + code: 'ENOENT', + }); + } + if (p === '/workspace') { + return '/real/workspace'; + } + throw new Error(`Unexpected path: ${p}`); }); + + const result = await tryRealpath('/workspace/nonexistent.txt'); + + // It should combine the real path of the parent with the original basename + expect(result).toBe(path.join('/real/workspace', 'nonexistent.txt')); }); - await expect(tryRealpath('/secret/file.txt')).rejects.toThrow( - 'EACCES: permission denied', - ); - }); -}); + it('should recursively fallback up the directory tree on multiple ENOENT errors', async () => { + vi.spyOn(fs, 'realpath').mockImplementation(async (p) => { + if (p === '/workspace/missing_dir/missing_file.txt') { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + } + if (p === '/workspace/missing_dir') { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + } + if (p === '/workspace') { + return '/real/workspace'; + } + throw new Error(`Unexpected path: ${p}`); + }); -describe('NoopSandboxManager', () => { - const sandboxManager = new NoopSandboxManager(); + const result = await tryRealpath( + '/workspace/missing_dir/missing_file.txt', + ); - it('should pass through the command and arguments unchanged', async () => { - const req = { - command: 'ls', - args: ['-la'], - cwd: '/tmp', - env: { PATH: '/usr/bin' }, - }; + // It should resolve '/workspace' to '/real/workspace' and append the missing parts + expect(result).toBe( + path.join('/real/workspace', 'missing_dir', 'missing_file.txt'), + ); + }); - const result = await sandboxManager.prepareCommand(req); + it('should return the path unchanged if it reaches the root directory and it still does not exist', async () => { + const rootPath = path.resolve('/'); + vi.spyOn(fs, 'realpath').mockImplementation(async () => { + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + }); - expect(result.program).toBe('ls'); - expect(result.args).toEqual(['-la']); + const result = await tryRealpath(rootPath); + expect(result).toBe(rootPath); + }); + + it('should throw an error if realpath fails with a non-ENOENT error (e.g. EACCES)', async () => { + vi.spyOn(fs, 'realpath').mockImplementation(async () => { + throw Object.assign(new Error('EACCES: permission denied'), { + code: 'EACCES', + }); + }); + + await expect(tryRealpath('/secret/file.txt')).rejects.toThrow( + 'EACCES: permission denied', + ); + }); }); - it('should sanitize the environment variables', async () => { - const req = { - command: 'echo', - args: ['hello'], - cwd: '/tmp', - env: { - PATH: '/usr/bin', - GITHUB_TOKEN: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', - MY_SECRET: 'super-secret', - SAFE_VAR: 'is-safe', - }, - }; + describe('NoopSandboxManager', () => { + const sandboxManager = new NoopSandboxManager(); - const result = await sandboxManager.prepareCommand(req); + it('should pass through the command and arguments unchanged', async () => { + const req = { + command: 'ls', + args: ['-la'], + cwd: '/tmp', + env: { PATH: '/usr/bin' }, + }; - expect(result.env['PATH']).toBe('/usr/bin'); - expect(result.env['SAFE_VAR']).toBe('is-safe'); - expect(result.env['GITHUB_TOKEN']).toBeUndefined(); - expect(result.env['MY_SECRET']).toBeUndefined(); - }); + const result = await sandboxManager.prepareCommand(req); - it('should NOT allow disabling environment variable redaction if requested in config (vulnerability fix)', async () => { - const req = { - command: 'echo', - args: ['hello'], - cwd: '/tmp', - env: { - API_KEY: 'sensitive-key', - }, - policy: { - sanitizationConfig: { - enableEnvironmentVariableRedaction: false, + expect(result.program).toBe('ls'); + expect(result.args).toEqual(['-la']); + }); + + it('should sanitize the environment variables', async () => { + const req = { + command: 'echo', + args: ['hello'], + cwd: '/tmp', + env: { + PATH: '/usr/bin', + GITHUB_TOKEN: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', + MY_SECRET: 'super-secret', + SAFE_VAR: 'is-safe', }, - }, - }; + }; - const result = await sandboxManager.prepareCommand(req); + const result = await sandboxManager.prepareCommand(req); - // API_KEY should be redacted because SandboxManager forces redaction and API_KEY matches NEVER_ALLOWED_NAME_PATTERNS - expect(result.env['API_KEY']).toBeUndefined(); - }); + expect(result.env['PATH']).toBe('/usr/bin'); + expect(result.env['SAFE_VAR']).toBe('is-safe'); + expect(result.env['GITHUB_TOKEN']).toBeUndefined(); + expect(result.env['MY_SECRET']).toBeUndefined(); + }); - it('should respect allowedEnvironmentVariables in config but filter sensitive ones', async () => { - const req = { - command: 'echo', - args: ['hello'], - cwd: '/tmp', - env: { - MY_SAFE_VAR: 'safe-value', - MY_TOKEN: 'secret-token', - }, - policy: { - sanitizationConfig: { - allowedEnvironmentVariables: ['MY_SAFE_VAR', 'MY_TOKEN'], + it('should NOT allow disabling environment variable redaction if requested in config (vulnerability fix)', async () => { + const req = { + command: 'echo', + args: ['hello'], + cwd: '/tmp', + env: { + API_KEY: 'sensitive-key', }, - }, - }; - - const result = await sandboxManager.prepareCommand(req); - - expect(result.env['MY_SAFE_VAR']).toBe('safe-value'); - // MY_TOKEN matches /TOKEN/i so it should be redacted despite being allowed in config - expect(result.env['MY_TOKEN']).toBeUndefined(); - }); - - it('should respect blockedEnvironmentVariables in config', async () => { - const req = { - command: 'echo', - args: ['hello'], - cwd: '/tmp', - env: { - SAFE_VAR: 'safe-value', - BLOCKED_VAR: 'blocked-value', - }, - policy: { - sanitizationConfig: { - blockedEnvironmentVariables: ['BLOCKED_VAR'], + policy: { + sanitizationConfig: { + enableEnvironmentVariableRedaction: false, + }, }, - }, - }; + }; - const result = await sandboxManager.prepareCommand(req); + const result = await sandboxManager.prepareCommand(req); - expect(result.env['SAFE_VAR']).toBe('safe-value'); - expect(result.env['BLOCKED_VAR']).toBeUndefined(); - }); -}); + // API_KEY should be redacted because SandboxManager forces redaction and API_KEY matches NEVER_ALLOWED_NAME_PATTERNS + expect(result.env['API_KEY']).toBeUndefined(); + }); -describe('createSandboxManager', () => { - it('should return NoopSandboxManager if sandboxing is disabled', () => { - const manager = createSandboxManager({ enabled: false }, '/workspace'); - expect(manager).toBeInstanceOf(NoopSandboxManager); + it('should respect allowedEnvironmentVariables in config but filter sensitive ones', async () => { + const req = { + command: 'echo', + args: ['hello'], + cwd: '/tmp', + env: { + MY_SAFE_VAR: 'safe-value', + MY_TOKEN: 'secret-token', + }, + policy: { + sanitizationConfig: { + allowedEnvironmentVariables: ['MY_SAFE_VAR', 'MY_TOKEN'], + }, + }, + }; + + const result = await sandboxManager.prepareCommand(req); + + expect(result.env['MY_SAFE_VAR']).toBe('safe-value'); + // MY_TOKEN matches /TOKEN/i so it should be redacted despite being allowed in config + expect(result.env['MY_TOKEN']).toBeUndefined(); + }); + + it('should respect blockedEnvironmentVariables in config', async () => { + const req = { + command: 'echo', + args: ['hello'], + cwd: '/tmp', + env: { + SAFE_VAR: 'safe-value', + BLOCKED_VAR: 'blocked-value', + }, + policy: { + sanitizationConfig: { + blockedEnvironmentVariables: ['BLOCKED_VAR'], + }, + }, + }; + + const result = await sandboxManager.prepareCommand(req); + + expect(result.env['SAFE_VAR']).toBe('safe-value'); + expect(result.env['BLOCKED_VAR']).toBeUndefined(); + }); + + it('should delegate isKnownSafeCommand to platform specific checkers', () => { + vi.spyOn(os, 'platform').mockReturnValue('darwin'); + expect(sandboxManager.isKnownSafeCommand(['ls'])).toBe(true); + expect(sandboxManager.isKnownSafeCommand(['dir'])).toBe(false); + + vi.spyOn(os, 'platform').mockReturnValue('win32'); + expect(sandboxManager.isKnownSafeCommand(['dir'])).toBe(true); + }); + + it('should delegate isDangerousCommand to platform specific checkers', () => { + vi.spyOn(os, 'platform').mockReturnValue('darwin'); + expect(sandboxManager.isDangerousCommand(['rm', '-rf', '.'])).toBe(true); + expect(sandboxManager.isDangerousCommand(['del'])).toBe(false); + + vi.spyOn(os, 'platform').mockReturnValue('win32'); + expect(sandboxManager.isDangerousCommand(['del'])).toBe(true); + }); }); - it.each([ - { platform: 'linux', expected: LinuxSandboxManager }, - { platform: 'darwin', expected: MacOsSandboxManager }, - { platform: 'win32', expected: WindowsSandboxManager }, - ] as const)( - 'should return $expected.name if sandboxing is enabled and platform is $platform', - ({ platform, expected }) => { - const osSpy = vi.spyOn(os, 'platform').mockReturnValue(platform); - try { + describe('createSandboxManager', () => { + it('should return NoopSandboxManager if sandboxing is disabled', () => { + const manager = createSandboxManager({ enabled: false }, '/workspace'); + expect(manager).toBeInstanceOf(NoopSandboxManager); + }); + + it.each([ + { platform: 'linux', expected: LinuxSandboxManager }, + { platform: 'darwin', expected: MacOsSandboxManager }, + ] as const)( + 'should return $expected.name if sandboxing is enabled and platform is $platform', + ({ platform, expected }) => { + vi.spyOn(os, 'platform').mockReturnValue(platform); const manager = createSandboxManager({ enabled: true }, '/workspace'); expect(manager).toBeInstanceOf(expected); - } finally { - osSpy.mockRestore(); - } - }, - ); + }, + ); + + it("should return WindowsSandboxManager if sandboxing is enabled with 'windows-native' command on win32", () => { + vi.spyOn(os, 'platform').mockReturnValue('win32'); + const manager = createSandboxManager( + { enabled: true, command: 'windows-native' }, + '/workspace', + ); + expect(manager).toBeInstanceOf(WindowsSandboxManager); + }); + + it('should return LocalSandboxManager on win32 if command is not windows-native', () => { + vi.spyOn(os, 'platform').mockReturnValue('win32'); + const manager = createSandboxManager( + { enabled: true, command: 'docker' as unknown as 'windows-native' }, + '/workspace', + ); + expect(manager).toBeInstanceOf(LocalSandboxManager); + }); + }); }); diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index c2f5a4c623..0e282b0748 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -7,6 +7,14 @@ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; +import { + isKnownSafeCommand as isMacSafeCommand, + isDangerousCommand as isMacDangerousCommand, +} from '../sandbox/macos/commandSafety.js'; +import { + isKnownSafeCommand as isWindowsSafeCommand, + isDangerousCommand as isWindowsDangerousCommand, +} from '../sandbox/windows/commandSafety.js'; import { isNodeError } from '../utils/errors.js'; import { sanitizeEnvironment, @@ -90,6 +98,16 @@ export interface SandboxManager { * Prepares a command to run in a sandbox, including environment sanitization. */ prepareCommand(req: SandboxRequest): Promise; + + /** + * Checks if a command with its arguments is known to be safe for this sandbox. + */ + isKnownSafeCommand(args: string[]): boolean; + + /** + * Checks if a command with its arguments is explicitly known to be dangerous for this sandbox. + */ + isDangerousCommand(args: string[]): boolean; } /** @@ -124,6 +142,18 @@ export class NoopSandboxManager implements SandboxManager { env: sanitizedEnv, }; } + + isKnownSafeCommand(args: string[]): boolean { + return os.platform() === 'win32' + ? isWindowsSafeCommand(args) + : isMacSafeCommand(args); + } + + isDangerousCommand(args: string[]): boolean { + return os.platform() === 'win32' + ? isWindowsDangerousCommand(args) + : isMacDangerousCommand(args); + } } /** @@ -133,6 +163,14 @@ export class LocalSandboxManager implements SandboxManager { async prepareCommand(_req: SandboxRequest): Promise { throw new Error('Tool sandboxing is not yet implemented.'); } + + isKnownSafeCommand(_args: string[]): boolean { + return false; + } + + isDangerousCommand(_args: string[]): boolean { + return false; + } } /** diff --git a/packages/core/src/services/sandboxManagerFactory.ts b/packages/core/src/services/sandboxManagerFactory.ts index 669257b7b0..bb8cea4752 100644 --- a/packages/core/src/services/sandboxManagerFactory.ts +++ b/packages/core/src/services/sandboxManagerFactory.ts @@ -29,24 +29,21 @@ export function createSandboxManager( return new NoopSandboxManager(); } - const isWindows = os.platform() === 'win32'; - - if ( - isWindows && - (sandbox?.enabled || sandbox?.command === 'windows-native') - ) { - return new WindowsSandboxManager({ workspace }); - } + const modeConfig = + policyManager && approvalMode + ? policyManager.getModeConfig(approvalMode) + : undefined; if (sandbox?.enabled) { - if (os.platform() === 'linux') { + if (os.platform() === 'win32' && sandbox?.command === 'windows-native') { + return new WindowsSandboxManager({ + workspace, + modeConfig, + policyManager, + }); + } else if (os.platform() === 'linux') { return new LinuxSandboxManager({ workspace }); - } - if (os.platform() === 'darwin') { - const modeConfig = - policyManager && approvalMode - ? policyManager.getModeConfig(approvalMode) - : undefined; + } else if (os.platform() === 'darwin') { return new MacOsSandboxManager({ workspace, modeConfig, diff --git a/packages/core/src/services/sandboxedFileSystemService.test.ts b/packages/core/src/services/sandboxedFileSystemService.test.ts index 9983bcfca7..046aadb132 100644 --- a/packages/core/src/services/sandboxedFileSystemService.test.ts +++ b/packages/core/src/services/sandboxedFileSystemService.test.ts @@ -35,6 +35,14 @@ class MockSandboxManager implements SandboxManager { env: req.env || {}, }; } + + isKnownSafeCommand(): boolean { + return false; + } + + isDangerousCommand(): boolean { + return false; + } } describe('SandboxedFileSystemService', () => { diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index a828771c25..6a0371b68d 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -1918,6 +1918,8 @@ describe('ShellExecutionService environment variables', () => { args: ['-c', 'ls'], env: { SANDBOXED: 'true' }, }), + isKnownSafeCommand: vi.fn().mockReturnValue(false), + isDangerousCommand: vi.fn().mockReturnValue(false), }; const configWithSandbox: ShellExecutionConfig = { diff --git a/packages/core/src/utils/shell-utils.ts b/packages/core/src/utils/shell-utils.ts index 119e8cd7f8..11e17ca358 100644 --- a/packages/core/src/utils/shell-utils.ts +++ b/packages/core/src/utils/shell-utils.ts @@ -7,12 +7,47 @@ import os from 'node:os'; import fs from 'node:fs'; import path from 'node:path'; -import { quote } from 'shell-quote'; +import { quote, type ParseEntry } from 'shell-quote'; import { spawn, spawnSync, type SpawnOptionsWithoutStdio, } from 'node:child_process'; + +/** + * Extracts the primary command name from a potentially wrapped shell command. + * Strips shell wrappers and handles shopt/set/etc. + * + * @param command - The full command string. + * @param args - The arguments for the command. + * @returns The primary command name. + */ +export async function getCommandName( + command: string, + args: string[], +): Promise { + await initializeShellParsers(); + const fullCmd = [command, ...args].join(' '); + const stripped = stripShellWrapper(fullCmd); + const roots = getCommandRoots(stripped).filter( + (r) => r !== 'shopt' && r !== 'set', + ); + if (roots.length > 0) { + return roots[0]; + } + return path.basename(command); +} + +/** + * Extracts a string representation from a shell-quote ParseEntry. + */ +export function extractStringFromParseEntry(entry: ParseEntry): string { + if (typeof entry === 'string') return entry; + if ('pattern' in entry) return entry.pattern; + if ('op' in entry) return entry.op; + if ('comment' in entry) return ''; // We can typically ignore comments for safety checks + return ''; +} import * as readline from 'node:readline'; import { Language, Parser, Query, type Node, type Tree } from 'web-tree-sitter'; import { loadWasmBinary } from './fileUtils.js'; From f618da15d69e87d2e4e97873825aa1cbbfa5ace8 Mon Sep 17 00:00:00 2001 From: Chris Williams Date: Wed, 25 Mar 2026 11:03:50 -0700 Subject: [PATCH 11/49] Add note about root privileges in sandbox docs (#23314) --- docs/reference/configuration.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 8b38dc1aff..869b8a0e21 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -2352,9 +2352,13 @@ can be based on the base sandbox image: ```dockerfile FROM gemini-cli-sandbox -# Add your custom dependencies or configurations here +# Add your custom dependencies or configurations here. +# Note: The base image runs as the non-root 'node' user. +# You must switch to 'root' to install system packages. # For example: +# USER root # RUN apt-get update && apt-get install -y some-package +# USER node # COPY ./my-config /app/my-config ``` From 0bb6c25dc7b9cf9c15e411d2266bd49d51e0da74 Mon Sep 17 00:00:00 2001 From: Adam Weidman <65992621+adamfweidman@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:04:28 -0400 Subject: [PATCH 12/49] docs(core): document agent_card_json string literal options for remote agents (#23797) --- docs/core/remote-agents.md | 102 ++++++++++++++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 6 deletions(-) diff --git a/docs/core/remote-agents.md b/docs/core/remote-agents.md index 05975421fe..e11c37fece 100644 --- a/docs/core/remote-agents.md +++ b/docs/core/remote-agents.md @@ -51,12 +51,13 @@ You can place them in: ### Configuration schema -| Field | Type | Required | Description | -| :--------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------- | -| `kind` | string | Yes | Must be `remote`. | -| `name` | string | Yes | A unique name for the agent. Must be a valid slug (lowercase letters, numbers, hyphens, and underscores only). | -| `agent_card_url` | string | Yes | The URL to the agent's A2A card endpoint. | -| `auth` | object | No | Authentication configuration. See [Authentication](#authentication). | +| Field | Type | Required | Description | +| :---------------- | :----- | :------- | :------------------------------------------------------------------------------------------------------------- | +| `kind` | string | Yes | Must be `remote`. | +| `name` | string | Yes | A unique name for the agent. Must be a valid slug (lowercase letters, numbers, hyphens, and underscores only). | +| `agent_card_url` | string | Yes\* | The URL to the agent's A2A card endpoint. Required if `agent_card_json` is not provided. | +| `agent_card_json` | string | Yes\* | The inline JSON string of the agent's A2A card. Required if `agent_card_url` is not provided. | +| `auth` | object | No | Authentication configuration. See [Authentication](#authentication). | ### Single-subagent example @@ -88,6 +89,95 @@ Markdown file. > [!NOTE] Mixed local and remote agents, or multiple local agents, are not > supported in a single file; the list format is currently remote-only. +### Inline Agent Card JSON + +
+View formatting options for JSON strings + +If you don't have an endpoint serving the agent card, you can provide the A2A +card directly as a JSON string using `agent_card_json`. + +When providing a JSON string in YAML, you must properly format it as a string +scalar. You can use single quotes, a block scalar, or double quotes (which +require escaping internal double quotes). + +#### Using single quotes + +Single quotes allow you to embed unescaped double quotes inside the JSON string. +This format is useful for shorter, single-line JSON strings. + +```markdown +--- +kind: remote +name: single-quotes-agent +agent_card_json: + '{ "protocolVersion": "0.3.0", "name": "Example Agent", "version": "1.0.0", + "url": "dummy-url" }' +--- +``` + +#### Using a block scalar + +The literal block scalar (`|`) preserves line breaks and is highly recommended +for multiline JSON strings as it avoids quote escaping entirely. The following +is a complete, valid Agent Card configuration using dummy values. + +```markdown +--- +kind: remote +name: block-scalar-agent +agent_card_json: | + { + "protocolVersion": "0.3.0", + "name": "Example Agent Name", + "description": "An example agent description for documentation purposes.", + "version": "1.0.0", + "url": "dummy-url", + "preferredTransport": "HTTP+JSON", + "capabilities": { + "streaming": true, + "extendedAgentCard": false + }, + "defaultInputModes": [ + "text/plain" + ], + "defaultOutputModes": [ + "application/json" + ], + "skills": [ + { + "id": "ExampleSkill", + "name": "Example Skill Assistant", + "description": "A description of what this example skill does.", + "tags": [ + "example-tag" + ], + "examples": [ + "Show me an example." + ] + } + ] + } +--- +``` + +#### Using double quotes + +Double quotes are also supported, but any internal double quotes in your JSON +must be escaped with a backslash. + +```markdown +--- +kind: remote +name: double-quotes-agent +agent_card_json: + '{ "protocolVersion": "0.3.0", "name": "Example Agent", "version": "1.0.0", + "url": "dummy-url" }' +--- +``` + +
+ ## Authentication Many remote agents require authentication. Gemini CLI supports several From 830f7dec61fe72936350b68c609835e0b3f7e13e Mon Sep 17 00:00:00 2001 From: Coco Sheng Date: Wed, 25 Mar 2026 14:18:43 -0400 Subject: [PATCH 13/49] fix(cli): resolve TTY hang on headless environments by unconditionally resuming process.stdin before React Ink launch (#23673) --- packages/cli/src/gemini.test.tsx | 56 ++++++++++++++ packages/cli/src/gemini.tsx | 9 +++ packages/core/src/code_assist/oauth2.test.ts | 79 ++++++++++++++++++++ packages/core/src/code_assist/oauth2.ts | 6 +- 4 files changed, 149 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx index 69ea6db56e..fd19ffa79c 100644 --- a/packages/cli/src/gemini.test.tsx +++ b/packages/cli/src/gemini.test.tsx @@ -528,6 +528,62 @@ describe('gemini.tsx main function kitty protocol', () => { ); }); + it('should call process.stdin.resume when isInteractive is true to protect against implicit Node pause', async () => { + const resumeSpy = vi.spyOn(process.stdin, 'resume'); + vi.mocked(loadCliConfig).mockResolvedValue( + createMockConfig({ + isInteractive: () => true, + getQuestion: () => '', + getSandbox: () => undefined, + }), + ); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + }, + }), + ); + vi.mocked(parseArguments).mockResolvedValue({ + model: undefined, + sandbox: undefined, + debug: undefined, + prompt: undefined, + promptInteractive: undefined, + query: undefined, + yolo: undefined, + approvalMode: undefined, + policy: undefined, + adminPolicy: undefined, + allowedMcpServerNames: undefined, + allowedTools: undefined, + experimentalAcp: undefined, + extensions: undefined, + listExtensions: undefined, + includeDirectories: undefined, + screenReader: undefined, + useWriteTodos: undefined, + resume: undefined, + listSessions: undefined, + deleteSession: undefined, + outputFormat: undefined, + fakeResponses: undefined, + recordResponses: undefined, + rawOutput: undefined, + acceptRawOutputRisk: undefined, + isCommand: undefined, + }); + + await act(async () => { + await main(); + }); + + expect(resumeSpy).toHaveBeenCalledTimes(1); + resumeSpy.mockRestore(); + }); + it.each([ { flag: 'listExtensions' }, { flag: 'listSessions' }, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 707774df57..4b43d7d81b 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -613,8 +613,17 @@ export async function main() { } cliStartupHandle?.end(); + // Render UI, passing necessary config values. Check that there is no command line question. if (config.isInteractive()) { + // Earlier initialization phases (like TerminalCapabilityManager resolving + // or authWithWeb) may have added and removed 'data' listeners on process.stdin. + // When the listener count drops to 0, Node.js implicitly pauses the stream buffer. + // React Ink's useInput hooks will silently fail to receive keystrokes if the stream remains paused. + if (process.stdin.isTTY) { + process.stdin.resume(); + } + await startInteractiveUI( config, settings, diff --git a/packages/core/src/code_assist/oauth2.test.ts b/packages/core/src/code_assist/oauth2.test.ts index afe35ce665..84a777820a 100644 --- a/packages/core/src/code_assist/oauth2.test.ts +++ b/packages/core/src/code_assist/oauth2.test.ts @@ -860,6 +860,85 @@ describe('oauth2', () => { global.setTimeout = originalSetTimeout; }); + it('should clear the authorization timeout immediately upon successful web login to prevent memory leaks', async () => { + const mockAuthUrl = 'https://example.com/auth'; + const mockCode = 'test-code'; + const mockState = 'test-state'; + + const mockOAuth2Client = { + generateAuthUrl: vi.fn().mockReturnValue(mockAuthUrl), + getToken: vi.fn().mockResolvedValue({ + tokens: { + access_token: 'test-token', + refresh_token: 'test-refresh', + }, + }), + setCredentials: vi.fn().mockImplementation(function ( + this: { credentials?: unknown }, + creds: unknown, + ) { + this.credentials = creds; + }), + getAccessToken: vi.fn().mockResolvedValue({ token: 'test-token' }), + on: vi.fn(), + credentials: {}, + } as unknown as OAuth2Client; + vi.mocked(OAuth2Client).mockImplementation(() => mockOAuth2Client); + + vi.spyOn(crypto, 'randomBytes').mockReturnValue(mockState as never); + vi.mocked(open).mockImplementation( + async () => ({ on: vi.fn() }) as never, + ); + + let requestCallback!: http.RequestListener; + let serverListeningCallback: (value: unknown) => void; + const serverListeningPromise = new Promise( + (resolve) => (serverListeningCallback = resolve), + ); + + const mockHttpServer = { + listen: vi.fn( + (_port: number, _host: string, callback?: () => void) => { + if (callback) callback(); + serverListeningCallback(undefined); + }, + ), + close: vi.fn(), + on: vi.fn(), + address: () => ({ port: 3000 }), + }; + (http.createServer as Mock).mockImplementation((cb) => { + requestCallback = cb; + return mockHttpServer as unknown as http.Server; + }); + + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + + const clientPromise = getOauthClient( + AuthType.LOGIN_WITH_GOOGLE, + mockConfig, + ); + await serverListeningPromise; + + const mockReq = { + url: `/oauth2callback?code=${mockCode}&state=${mockState}`, + } as http.IncomingMessage; + const mockRes = { + writeHead: vi.fn(), + end: vi.fn(), + on: vi.fn(), + } as unknown as http.ServerResponse; + + // Trigger the successful server response + requestCallback(mockReq, mockRes); + await clientPromise; + + // Verify that the watchdog timer was cleared correctly + expect(clearTimeoutSpy).toHaveBeenCalled(); + + clearTimeoutSpy.mockRestore(); + }); + it('should handle OAuth callback errors with descriptive messages', async () => { const mockAuthUrl = 'https://example.com/auth'; const mockOAuth2Client = { diff --git a/packages/core/src/code_assist/oauth2.ts b/packages/core/src/code_assist/oauth2.ts index e238a4a860..0ae523dc94 100644 --- a/packages/core/src/code_assist/oauth2.ts +++ b/packages/core/src/code_assist/oauth2.ts @@ -332,8 +332,9 @@ async function initOauthClient( // Add timeout to prevent infinite waiting when browser tab gets stuck const authTimeout = 5 * 60 * 1000; // 5 minutes timeout + let timeoutId: NodeJS.Timeout | undefined; const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => { + timeoutId = setTimeout(() => { reject( new FatalAuthenticationError( 'Authentication timed out after 5 minutes. The browser tab may have gotten stuck in a loading state. ' + @@ -371,6 +372,9 @@ async function initOauthClient( cancellationPromise, ]); } finally { + if (timeoutId) { + clearTimeout(timeoutId); + } if (sigIntHandler) { process.removeListener('SIGINT', sigIntHandler); } From fe92a43e3118aaa0b6cca0303e08f35ffc6c3427 Mon Sep 17 00:00:00 2001 From: Keith Guerin Date: Wed, 25 Mar 2026 12:15:08 -0700 Subject: [PATCH 14/49] fix(ui): cleanup estimated string length hacks in composer (#23694) --- packages/cli/src/ui/components/Composer.tsx | 461 +----------------- packages/cli/src/ui/components/StatusRow.tsx | 424 ++++++++++++++++ .../cli/src/ui/hooks/useComposerStatus.ts | 110 +++++ packages/cli/src/ui/hooks/usePhraseCycler.ts | 6 +- 4 files changed, 557 insertions(+), 444 deletions(-) create mode 100644 packages/cli/src/ui/components/StatusRow.tsx create mode 100644 packages/cli/src/ui/hooks/useComposerStatus.ts diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index af6d3b32da..5c9850bf92 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -4,14 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - ApprovalMode, - checkExhaustive, - CoreToolCallStatus, - isUserVisibleHook, -} from '@google/gemini-cli-core'; -import { Box, Text, useIsScreenReaderEnabled } from 'ink'; -import { useState, useEffect, useMemo } from 'react'; +import { Box, useIsScreenReaderEnabled } from 'ink'; +import { useState, useEffect } from 'react'; import { useConfig } from '../contexts/ConfigContext.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; @@ -20,28 +14,18 @@ import { useVimMode } from '../contexts/VimModeContext.js'; import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js'; -import { isContextUsageHigh } from '../utils/contextUsage.js'; -import { theme } from '../semantic-colors.js'; -import { GENERIC_WORKING_LABEL } from '../textConstants.js'; -import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js'; -import { StreamingState, type HistoryItemToolGroup } from '../types.js'; -import { LoadingIndicator } from './LoadingIndicator.js'; -import { ContextUsageDisplay } from './ContextUsageDisplay.js'; -import { StatusDisplay } from './StatusDisplay.js'; -import { HorizontalLine } from './shared/HorizontalLine.js'; import { ToastDisplay, shouldShowToast } from './ToastDisplay.js'; -import { ApprovalModeIndicator } from './ApprovalModeIndicator.js'; -import { ShellModeIndicator } from './ShellModeIndicator.js'; import { DetailedMessagesDisplay } from './DetailedMessagesDisplay.js'; -import { RawMarkdownIndicator } from './RawMarkdownIndicator.js'; import { ShortcutsHelp } from './ShortcutsHelp.js'; import { InputPrompt } from './InputPrompt.js'; import { Footer } from './Footer.js'; +import { StatusRow } from './StatusRow.js'; import { ShowMoreLines } from './ShowMoreLines.js'; import { QueuedMessageDisplay } from './QueuedMessageDisplay.js'; import { OverflowProvider } from '../contexts/OverflowContext.js'; import { ConfigInitDisplay } from './ConfigInitDisplay.js'; import { TodoTray } from './messages/Todo.js'; +import { useComposerStatus } from '../hooks/useComposerStatus.js'; export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const uiState = useUIState(); @@ -56,43 +40,17 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const [suggestionsVisible, setSuggestionsVisible] = useState(false); const isAlternateBuffer = useAlternateBuffer(); - const showApprovalModeIndicator = uiState.showApprovalModeIndicator; - const loadingPhrases = settings.merged.ui.loadingPhrases; - const showTips = loadingPhrases === 'tips' || loadingPhrases === 'all'; - const showWit = loadingPhrases === 'witty' || loadingPhrases === 'all'; - const showUiDetails = uiState.cleanUiDetailsVisible; const suggestionsPosition = isAlternateBuffer ? 'above' : 'below'; const hideContextSummary = suggestionsVisible && suggestionsPosition === 'above'; - const hasPendingToolConfirmation = useMemo( - () => - (uiState.pendingHistoryItems ?? []) - .filter( - (item): item is HistoryItemToolGroup => item.type === 'tool_group', - ) - .some((item) => - item.tools.some( - (tool) => tool.status === CoreToolCallStatus.AwaitingApproval, - ), - ), - [uiState.pendingHistoryItems], - ); - - const hasPendingActionRequired = - hasPendingToolConfirmation || - Boolean(uiState.commandConfirmationRequest) || - Boolean(uiState.authConsentRequest) || - (uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 || - Boolean(uiState.loopDetectionConfirmationRequest) || - Boolean(uiState.quota.proQuotaRequest) || - Boolean(uiState.quota.validationRequest) || - Boolean(uiState.customDialog); + const { hasPendingActionRequired, shouldCollapseDuringApproval } = + useComposerStatus(); const isPassiveShortcutsHelpState = uiState.isInputActive && - uiState.streamingState === StreamingState.Idle && + uiState.streamingState === 'idle' && !hasPendingActionRequired; const { setShortcutsHelpVisible } = uiActions; @@ -109,407 +67,19 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const showShortcutsHelp = uiState.shortcutsHelpVisible && - uiState.streamingState === StreamingState.Idle && + uiState.streamingState === 'idle' && !hasPendingActionRequired; - /** - * Use the setting if provided, otherwise default to true for the new UX. - * This allows tests to override the collapse behavior. - */ - const shouldCollapseDuringApproval = - settings.merged.ui.collapseDrawerDuringApproval !== false; - if (hasPendingActionRequired && shouldCollapseDuringApproval) { return null; } const hasToast = shouldShowToast(uiState); - const showLoadingIndicator = - (!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && - uiState.streamingState === StreamingState.Responding && - !hasPendingActionRequired; - const hideUiDetailsForSuggestions = suggestionsVisible && suggestionsPosition === 'above'; - const showApprovalIndicator = - !uiState.shellModeActive && !hideUiDetailsForSuggestions; - const showRawMarkdownIndicator = !uiState.renderMarkdown; - - let modeBleedThrough: { text: string; color: string } | null = null; - switch (showApprovalModeIndicator) { - case ApprovalMode.YOLO: - modeBleedThrough = { text: 'YOLO', color: theme.status.error }; - break; - case ApprovalMode.PLAN: - modeBleedThrough = { text: 'plan', color: theme.status.success }; - break; - case ApprovalMode.AUTO_EDIT: - modeBleedThrough = { text: 'auto edit', color: theme.status.warning }; - break; - case ApprovalMode.DEFAULT: - modeBleedThrough = null; - break; - default: - checkExhaustive(showApprovalModeIndicator); - modeBleedThrough = null; - break; - } - - const hideMinimalModeHintWhileBusy = - !showUiDetails && (showLoadingIndicator || hasPendingActionRequired); - - // Universal Content Objects - const modeContentObj = hideMinimalModeHintWhileBusy ? null : modeBleedThrough; - - const allHooks = uiState.activeHooks; - const hasAnyHooks = allHooks.length > 0; - const userVisibleHooks = allHooks.filter((h) => isUserVisibleHook(h.source)); - const hasUserVisibleHooks = userVisibleHooks.length > 0; - - const shouldReserveSpaceForShortcutsHint = - settings.merged.ui.showShortcutsHint && - !hideUiDetailsForSuggestions && - !hasPendingActionRequired; - - const isInteractiveShellWaiting = uiState.currentLoadingPhrase?.includes( - INTERACTIVE_SHELL_WAITING_PHRASE, - ); - - /** - * Calculate the estimated length of the status message to avoid collisions - * with the tips area. - */ - let estimatedStatusLength = 0; - if (hasAnyHooks) { - if (hasUserVisibleHooks) { - const hookLabel = - userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook'; - const hookNames = userVisibleHooks - .map( - (h) => - h.name + - (h.index && h.total && h.total > 1 - ? ` (${h.index}/${h.total})` - : ''), - ) - .join(', '); - estimatedStatusLength = hookLabel.length + hookNames.length + 10; - } else { - estimatedStatusLength = GENERIC_WORKING_LABEL.length + 10; - } - } else if (showLoadingIndicator) { - const thoughtText = uiState.thought?.subject || GENERIC_WORKING_LABEL; - const inlineWittyLength = - showWit && uiState.currentWittyPhrase - ? uiState.currentWittyPhrase.length + 1 - : 0; - estimatedStatusLength = thoughtText.length + 25 + inlineWittyLength; - } else if (hasPendingActionRequired) { - estimatedStatusLength = 20; - } else if (hasToast) { - estimatedStatusLength = 40; - } - - /** - * Determine the ambient text (tip) to display. - */ - const tipContentStr = (() => { - // 1. Proactive Tip (Priority) - if ( - showTips && - uiState.currentTip && - !( - isInteractiveShellWaiting && - uiState.currentTip === INTERACTIVE_SHELL_WAITING_PHRASE - ) - ) { - if ( - estimatedStatusLength + uiState.currentTip.length + 10 <= - terminalWidth - ) { - return uiState.currentTip; - } - } - - // 2. Shortcut Hint (Fallback) - if ( - settings.merged.ui.showShortcutsHint && - !hideUiDetailsForSuggestions && - !hasPendingActionRequired && - uiState.buffer.text.length === 0 - ) { - return showUiDetails ? '? for shortcuts' : 'press tab twice for more'; - } - - return undefined; - })(); - - const tipLength = tipContentStr?.length || 0; - const willCollideTip = estimatedStatusLength + tipLength + 5 > terminalWidth; - - const showTipLine = - !hasPendingActionRequired && tipContentStr && !willCollideTip && !isNarrow; // Mini Mode VIP Flags (Pure Content Triggers) - const miniMode_ShowApprovalMode = - Boolean(modeContentObj) && !hideUiDetailsForSuggestions; - const miniMode_ShowToast = hasToast; - const miniMode_ShowShortcuts = shouldReserveSpaceForShortcutsHint; - const miniMode_ShowStatus = showLoadingIndicator || hasAnyHooks; - const miniMode_ShowTip = showTipLine; - const miniMode_ShowContext = isContextUsageHigh( - uiState.sessionStats.lastPromptTokenCount, - uiState.currentModel, - settings.merged.model?.compressionThreshold, - ); - - // Composite Mini Mode Triggers - const showRow1_MiniMode = - miniMode_ShowToast || - miniMode_ShowStatus || - miniMode_ShowShortcuts || - miniMode_ShowTip; - - const showRow2_MiniMode = miniMode_ShowApprovalMode || miniMode_ShowContext; - - // Final Display Rules (Stable Footer Architecture) - const showRow1 = showUiDetails || showRow1_MiniMode; - const showRow2 = showUiDetails || showRow2_MiniMode; - - const showMinimalBleedThroughRow = !showUiDetails && showRow2_MiniMode; - - const renderTipNode = () => { - if (!tipContentStr) return null; - - const isShortcutHint = - tipContentStr === '? for shortcuts' || - tipContentStr === 'press tab twice for more'; - const color = - isShortcutHint && uiState.shortcutsHelpVisible - ? theme.text.accent - : theme.text.secondary; - - return ( - - - {tipContentStr === uiState.currentTip - ? `Tip: ${tipContentStr}` - : tipContentStr} - - - ); - }; - - const renderStatusNode = () => { - const allHooks = uiState.activeHooks; - if (allHooks.length === 0 && !showLoadingIndicator) return null; - - if (allHooks.length > 0) { - const userVisibleHooks = allHooks.filter((h) => - isUserVisibleHook(h.source), - ); - - let hookText = GENERIC_WORKING_LABEL; - if (userVisibleHooks.length > 0) { - const label = - userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook'; - const displayNames = userVisibleHooks.map((h) => { - let name = h.name; - if (h.index && h.total && h.total > 1) { - name += ` (${h.index}/${h.total})`; - } - return name; - }); - hookText = `${label}: ${displayNames.join(', ')}`; - } - - return ( - - ); - } - - return ( - - ); - }; - - const statusNode = renderStatusNode(); - - /** - * Renders the minimal metadata row content shown when UI details are hidden. - */ - const renderMinimalMetaRowContent = () => ( - - {renderStatusNode()} - {showMinimalBleedThroughRow && ( - - {miniMode_ShowApprovalMode && modeContentObj && ( - ● {modeContentObj.text} - )} - - )} - - ); - - const renderStatusRow = () => { - // Mini Mode Height Reservation (The "Anti-Jitter" line) - if (!showUiDetails && !showRow1_MiniMode && !showRow2_MiniMode) { - return ; - } - - return ( - - {/* Row 1: multipurpose status (thinking, hooks, wit, tips) */} - {showRow1 && ( - - - {!showUiDetails && showRow1_MiniMode ? ( - renderMinimalMetaRowContent() - ) : isInteractiveShellWaiting ? ( - - - ! Shell awaiting input (Tab to focus) - - - ) : ( - - {statusNode} - - )} - - - - {!isNarrow && showTipLine && renderTipNode()} - - - )} - - {/* Internal Separator Line */} - {showRow1 && - showRow2 && - (showUiDetails || (showRow1_MiniMode && showRow2_MiniMode)) && ( - - - - )} - - {/* Row 2: Mode and Context Summary */} - {showRow2 && ( - - - {showUiDetails ? ( - <> - {showApprovalIndicator && ( - - )} - {uiState.shellModeActive && ( - - - - )} - {showRawMarkdownIndicator && ( - - - - )} - - ) : ( - miniMode_ShowApprovalMode && - modeContentObj && ( - - ● {modeContentObj.text} - - ) - )} - - - {(showUiDetails || miniMode_ShowContext) && ( - - )} - {miniMode_ShowContext && !showUiDetails && ( - - - - )} - - - )} - - ); - }; + const showMinimalToast = hasToast; return ( { {showShortcutsHelp && } - {(showUiDetails || miniMode_ShowToast) && ( + {(showUiDetails || showMinimalToast) && ( )} - {renderStatusRow()} + {showUiDetails && uiState.showErrorDetails && ( @@ -569,7 +146,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { commandContext={uiState.commandContext} shellModeActive={uiState.shellModeActive} setShellModeActive={uiActions.setShellModeActive} - approvalMode={showApprovalModeIndicator} + approvalMode={uiState.showApprovalModeIndicator} onEscapePromptChange={uiActions.onEscapePromptChange} focus={isFocused} vimHandleInput={uiActions.vimHandleInput} diff --git a/packages/cli/src/ui/components/StatusRow.tsx b/packages/cli/src/ui/components/StatusRow.tsx new file mode 100644 index 0000000000..4585438bee --- /dev/null +++ b/packages/cli/src/ui/components/StatusRow.tsx @@ -0,0 +1,424 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useCallback, useRef, useState } from 'react'; +import { Box, Text, ResizeObserver, type DOMElement } from 'ink'; +import { + isUserVisibleHook, + type ThoughtSummary, +} from '@google/gemini-cli-core'; +import stripAnsi from 'strip-ansi'; +import { type ActiveHook } from '../types.js'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; +import { theme } from '../semantic-colors.js'; +import { GENERIC_WORKING_LABEL } from '../textConstants.js'; +import { INTERACTIVE_SHELL_WAITING_PHRASE } from '../hooks/usePhraseCycler.js'; +import { LoadingIndicator } from './LoadingIndicator.js'; +import { StatusDisplay } from './StatusDisplay.js'; +import { ContextUsageDisplay } from './ContextUsageDisplay.js'; +import { HorizontalLine } from './shared/HorizontalLine.js'; +import { ApprovalModeIndicator } from './ApprovalModeIndicator.js'; +import { ShellModeIndicator } from './ShellModeIndicator.js'; +import { RawMarkdownIndicator } from './RawMarkdownIndicator.js'; +import { useComposerStatus } from '../hooks/useComposerStatus.js'; + +/** + * Layout constants to prevent magic numbers. + */ +const LAYOUT = { + STATUS_MIN_HEIGHT: 1, + TIP_LEFT_MARGIN: 2, + TIP_RIGHT_MARGIN_NARROW: 0, + TIP_RIGHT_MARGIN_WIDE: 1, + INDICATOR_LEFT_MARGIN: 1, + CONTEXT_DISPLAY_TOP_MARGIN_NARROW: 1, + CONTEXT_DISPLAY_LEFT_MARGIN_NARROW: 1, + CONTEXT_DISPLAY_LEFT_MARGIN_WIDE: 0, + COLLISION_GAP: 10, +}; + +interface StatusRowProps { + showUiDetails: boolean; + isNarrow: boolean; + terminalWidth: number; + hideContextSummary: boolean; + hideUiDetailsForSuggestions: boolean; + hasPendingActionRequired: boolean; +} + +/** + * Renders the loading or hook execution status. + */ +export const StatusNode: React.FC<{ + showTips: boolean; + showWit: boolean; + thought: ThoughtSummary | null; + elapsedTime: number; + currentWittyPhrase: string | undefined; + activeHooks: ActiveHook[]; + showLoadingIndicator: boolean; + errorVerbosity: 'low' | 'full' | undefined; + onResize?: (width: number) => void; +}> = ({ + showTips, + showWit, + thought, + elapsedTime, + currentWittyPhrase, + activeHooks, + showLoadingIndicator, + errorVerbosity, + onResize, +}) => { + const observerRef = useRef(null); + + const onRefChange = useCallback( + (node: DOMElement | null) => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + + if (node && onResize) { + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + onResize(Math.round(entry.contentRect.width)); + } + }); + observer.observe(node); + observerRef.current = observer; + } + }, + [onResize], + ); + + if (activeHooks.length === 0 && !showLoadingIndicator) return null; + + let currentLoadingPhrase: string | undefined = undefined; + let currentThought: ThoughtSummary | null = null; + + if (activeHooks.length > 0) { + const userVisibleHooks = activeHooks.filter((h) => + isUserVisibleHook(h.source), + ); + + if (userVisibleHooks.length > 0) { + const label = + userVisibleHooks.length > 1 ? 'Executing Hooks' : 'Executing Hook'; + const displayNames = userVisibleHooks.map((h) => { + let name = stripAnsi(h.name); + if (h.index && h.total && h.total > 1) { + name += ` (${h.index}/${h.total})`; + } + return name; + }); + currentLoadingPhrase = `${label}: ${displayNames.join(', ')}`; + } else { + currentLoadingPhrase = GENERIC_WORKING_LABEL; + } + } else { + // Sanitize thought subject to prevent terminal injection + currentThought = thought + ? { ...thought, subject: stripAnsi(thought.subject) } + : null; + } + + return ( + + + + ); +}; + +export const StatusRow: React.FC = ({ + showUiDetails, + isNarrow, + terminalWidth, + hideContextSummary, + hideUiDetailsForSuggestions, + hasPendingActionRequired, +}) => { + const uiState = useUIState(); + const settings = useSettings(); + const { + isInteractiveShellWaiting, + showLoadingIndicator, + showTips, + showWit, + modeContentObj, + showMinimalContext, + } = useComposerStatus(); + + const [statusWidth, setStatusWidth] = useState(0); + const [tipWidth, setTipWidth] = useState(0); + const tipObserverRef = useRef(null); + + const onTipRefChange = useCallback((node: DOMElement | null) => { + if (tipObserverRef.current) { + tipObserverRef.current.disconnect(); + tipObserverRef.current = null; + } + + if (node) { + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + setTipWidth(Math.round(entry.contentRect.width)); + } + }); + observer.observe(node); + tipObserverRef.current = observer; + } + }, []); + + const tipContentStr = (() => { + // 1. Proactive Tip (Priority) + if ( + showTips && + uiState.currentTip && + !( + isInteractiveShellWaiting && + uiState.currentTip === INTERACTIVE_SHELL_WAITING_PHRASE + ) + ) { + return uiState.currentTip; + } + + // 2. Shortcut Hint (Fallback) + if ( + settings.merged.ui.showShortcutsHint && + !hideUiDetailsForSuggestions && + !hasPendingActionRequired && + uiState.buffer.text.length === 0 + ) { + return showUiDetails ? '? for shortcuts' : 'press tab twice for more'; + } + + return undefined; + })(); + + // Collision detection using measured widths + const willCollideTip = + statusWidth + tipWidth + LAYOUT.COLLISION_GAP > terminalWidth; + + const showTipLine = Boolean( + !hasPendingActionRequired && tipContentStr && !willCollideTip && !isNarrow, + ); + + const showRow1Minimal = + showLoadingIndicator || uiState.activeHooks.length > 0 || showTipLine; + const showRow2Minimal = + (Boolean(modeContentObj) && !hideUiDetailsForSuggestions) || + showMinimalContext; + + const showRow1 = showUiDetails || showRow1Minimal; + const showRow2 = showUiDetails || showRow2Minimal; + + const statusNode = ( + + ); + + const renderTipNode = () => { + if (!tipContentStr) return null; + + const isShortcutHint = + tipContentStr === '? for shortcuts' || + tipContentStr === 'press tab twice for more'; + const color = + isShortcutHint && uiState.shortcutsHelpVisible + ? theme.text.accent + : theme.text.secondary; + + return ( + + + {tipContentStr === uiState.currentTip + ? `Tip: ${tipContentStr}` + : tipContentStr} + + + ); + }; + + if (!showUiDetails && !showRow1Minimal && !showRow2Minimal) { + return ; + } + + return ( + + {/* Row 1: Status & Tips */} + {showRow1 && ( + + + {!showUiDetails && showRow1Minimal ? ( + + {statusNode} + {!showUiDetails && showRow2Minimal && modeContentObj && ( + + + ● {modeContentObj.text} + + + )} + + ) : isInteractiveShellWaiting ? ( + + + ! Shell awaiting input (Tab to focus) + + + ) : ( + + {statusNode} + + )} + + + + {/* + We always render the tip node so it can be measured by ResizeObserver, + but we control its visibility based on the collision detection. + */} + + {!isNarrow && tipContentStr && renderTipNode()} + + + + )} + + {/* Internal Separator */} + {showRow1 && + showRow2 && + (showUiDetails || (showRow1Minimal && showRow2Minimal)) && ( + + + + )} + + {/* Row 2: Modes & Context */} + {showRow2 && ( + + + {showUiDetails ? ( + <> + {!hideUiDetailsForSuggestions && !uiState.shellModeActive && ( + + )} + {uiState.shellModeActive && ( + + + + )} + {!uiState.renderMarkdown && ( + + + + )} + + ) : ( + showRow2Minimal && + modeContentObj && ( + + ● {modeContentObj.text} + + ) + )} + + + {(showUiDetails || showMinimalContext) && ( + + )} + {showMinimalContext && !showUiDetails && ( + + + + )} + + + )} + + ); +}; diff --git a/packages/cli/src/ui/hooks/useComposerStatus.ts b/packages/cli/src/ui/hooks/useComposerStatus.ts new file mode 100644 index 0000000000..0f82e650aa --- /dev/null +++ b/packages/cli/src/ui/hooks/useComposerStatus.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useMemo } from 'react'; +import { useUIState } from '../contexts/UIStateContext.js'; +import { useSettings } from '../contexts/SettingsContext.js'; +import { CoreToolCallStatus, ApprovalMode } from '@google/gemini-cli-core'; +import { type HistoryItemToolGroup, StreamingState } from '../types.js'; +import { INTERACTIVE_SHELL_WAITING_PHRASE } from './usePhraseCycler.js'; +import { isContextUsageHigh } from '../utils/contextUsage.js'; +import { theme } from '../semantic-colors.js'; + +/** + * A hook that encapsulates complex status and action-required logic for the Composer. + */ +export const useComposerStatus = () => { + const uiState = useUIState(); + const settings = useSettings(); + + const hasPendingToolConfirmation = useMemo( + () => + (uiState.pendingHistoryItems ?? []) + .filter( + (item): item is HistoryItemToolGroup => item.type === 'tool_group', + ) + .some((item) => + item.tools.some( + (tool) => tool.status === CoreToolCallStatus.AwaitingApproval, + ), + ), + [uiState.pendingHistoryItems], + ); + + const hasPendingActionRequired = + hasPendingToolConfirmation || + Boolean(uiState.commandConfirmationRequest) || + Boolean(uiState.authConsentRequest) || + (uiState.confirmUpdateExtensionRequests?.length ?? 0) > 0 || + Boolean(uiState.loopDetectionConfirmationRequest) || + Boolean(uiState.quota.proQuotaRequest) || + Boolean(uiState.quota.validationRequest) || + Boolean(uiState.customDialog); + + const isInteractiveShellWaiting = Boolean( + uiState.currentLoadingPhrase?.includes(INTERACTIVE_SHELL_WAITING_PHRASE), + ); + + const showLoadingIndicator = + (!uiState.embeddedShellFocused || uiState.isBackgroundShellVisible) && + uiState.streamingState === StreamingState.Responding && + !hasPendingActionRequired; + + const showApprovalModeIndicator = uiState.showApprovalModeIndicator; + + const modeContentObj = useMemo(() => { + const hideMinimalModeHintWhileBusy = + !uiState.cleanUiDetailsVisible && + (showLoadingIndicator || uiState.activeHooks.length > 0); + + if (hideMinimalModeHintWhileBusy) return null; + + switch (showApprovalModeIndicator) { + case ApprovalMode.YOLO: + return { text: 'YOLO', color: theme.status.error }; + case ApprovalMode.PLAN: + return { text: 'plan', color: theme.status.success }; + case ApprovalMode.AUTO_EDIT: + return { text: 'auto edit', color: theme.status.warning }; + case ApprovalMode.DEFAULT: + default: + return null; + } + }, [ + uiState.cleanUiDetailsVisible, + showLoadingIndicator, + uiState.activeHooks.length, + showApprovalModeIndicator, + ]); + + const showMinimalContext = isContextUsageHigh( + uiState.sessionStats.lastPromptTokenCount, + uiState.currentModel, + settings.merged.model?.compressionThreshold, + ); + + const loadingPhrases = settings.merged.ui.loadingPhrases; + const showTips = loadingPhrases === 'tips' || loadingPhrases === 'all'; + const showWit = loadingPhrases === 'witty' || loadingPhrases === 'all'; + + /** + * Use the setting if provided, otherwise default to true for the new UX. + * This allows tests to override the collapse behavior. + */ + const shouldCollapseDuringApproval = + settings.merged.ui.collapseDrawerDuringApproval !== false; + + return { + hasPendingActionRequired, + shouldCollapseDuringApproval, + isInteractiveShellWaiting, + showLoadingIndicator, + showTips, + showWit, + modeContentObj, + showMinimalContext, + }; +}; diff --git a/packages/cli/src/ui/hooks/usePhraseCycler.ts b/packages/cli/src/ui/hooks/usePhraseCycler.ts index 1b82336afe..5bae72f172 100644 --- a/packages/cli/src/ui/hooks/usePhraseCycler.ts +++ b/packages/cli/src/ui/hooks/usePhraseCycler.ts @@ -66,11 +66,11 @@ export const usePhraseCycler = ( if (shouldShowFocusHint || isWaiting) { // These are handled by the return value directly for immediate feedback - return; + return clearTimers; } if (!isActive || (!showTips && !showWit)) { - return; + return clearTimers; } const wittyPhrasesList = @@ -101,6 +101,7 @@ export const usePhraseCycler = ( : INFORMATIVE_TIPS; if (filteredTips.length > 0) { + // codeql[js/insecure-randomness] false positive: used for non-sensitive UI flavor text (tips) const selected = filteredTips[Math.floor(Math.random() * filteredTips.length)]; setCurrentTipState(selected); @@ -132,6 +133,7 @@ export const usePhraseCycler = ( : wittyPhrasesList; if (filteredWitty.length > 0) { + // codeql[js/insecure-randomness] false positive: used for non-sensitive UI flavor text (witty phrases) const selected = filteredWitty[Math.floor(Math.random() * filteredWitty.length)]; setCurrentWittyPhraseState(selected); From 86111c4d54b9978aab0ab0656c6a025f969f9843 Mon Sep 17 00:00:00 2001 From: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com> Date: Wed, 25 Mar 2026 12:16:48 -0700 Subject: [PATCH 15/49] feat(browser): dynamically discover read-only tools (#23805) --- .../agents/browser/browserAgentFactory.test.ts | 16 +++++++++++++--- .../src/agents/browser/browserAgentFactory.ts | 13 ++++++------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/core/src/agents/browser/browserAgentFactory.test.ts b/packages/core/src/agents/browser/browserAgentFactory.test.ts index 270b400c3b..003ba465c4 100644 --- a/packages/core/src/agents/browser/browserAgentFactory.test.ts +++ b/packages/core/src/agents/browser/browserAgentFactory.test.ts @@ -379,9 +379,19 @@ describe('browserAgentFactory', () => { it('should register ALLOW rules for read-only tools', async () => { mockBrowserManager.getDiscoveredTools.mockResolvedValue([ - { name: 'take_snapshot', description: 'Take snapshot' }, - { name: 'take_screenshot', description: 'Take screenshot' }, - { name: 'list_pages', description: 'list all pages' }, + { + name: 'take_snapshot', + description: 'Take snapshot', + }, + { + name: 'take_screenshot', + description: 'Take screenshot', + }, + { + name: 'list_pages', + description: 'list all pages', + annotations: { readOnlyHint: true }, + }, ]); await createBrowserAgentDefinition(mockConfig, mockMessageBus); diff --git a/packages/core/src/agents/browser/browserAgentFactory.ts b/packages/core/src/agents/browser/browserAgentFactory.ts index ab42229e89..0d28651c12 100644 --- a/packages/core/src/agents/browser/browserAgentFactory.ts +++ b/packages/core/src/agents/browser/browserAgentFactory.ts @@ -120,13 +120,12 @@ export async function createBrowserAgentDefinition( } // Reduce noise for read-only tools in default mode - const readOnlyTools = [ - 'take_snapshot', - 'take_screenshot', - 'list_pages', - 'list_network_requests', - ]; - for (const toolName of readOnlyTools) { + const readOnlyTools = (await browserManager.getDiscoveredTools()) + .filter((t) => !!t.annotations?.readOnlyHint) + .map((t) => t.name); + const allowlistedReadonlyTools = ['take_snapshot', 'take_screenshot']; + + for (const toolName of [...readOnlyTools, ...allowlistedReadonlyTools]) { if (availableToolNames.includes(toolName)) { const rule = generateAllowRules(toolName); if (!existingRules.some((r) => isRuleEqual(r, rule))) { From 6d3437badb7caa12d387c4bdd21b4cd1da36fe13 Mon Sep 17 00:00:00 2001 From: Jerop Kipruto Date: Wed, 25 Mar 2026 15:37:48 -0400 Subject: [PATCH 16/49] docs: clarify policy requirement for `general.plan.directory` in settings schema (#23784) --- docs/cli/settings.md | 2 +- docs/reference/configuration.md | 3 ++- packages/cli/src/config/settingsSchema.ts | 2 +- schemas/settings.schema.json | 4 ++-- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 2a4b5963ce..2792606959 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -30,7 +30,7 @@ they appear in the UI. | Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). | `"default"` | | 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 Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode. | `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` | | Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` | | Max Chat Model Attempts | `general.maxAttempts` | Maximum number of attempts for requests to the main chat model. Cannot exceed 10. | `10` | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 869b8a0e21..5c4ef25544 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -143,7 +143,8 @@ their corresponding top-level category object in your `settings.json` file. - **`general.plan.directory`** (string): - **Description:** The directory where planning artifacts are stored. If not - specified, defaults to the system temporary directory. + specified, defaults to the system temporary directory. A custom directory + requires a policy to allow write access in Plan Mode. - **Default:** `undefined` - **Requires restart:** Yes diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index c0f2395110..891e383bc9 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -300,7 +300,7 @@ const SETTINGS_SCHEMA = { requiresRestart: true, default: undefined as string | undefined, description: - 'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory.', + 'The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode.', showInDialog: true, }, modelRouting: { diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index f023d17dd7..b84e660262 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -124,8 +124,8 @@ "properties": { "directory": { "title": "Plan Directory", - "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`", + "description": "The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode.", + "markdownDescription": "The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode.\n\n- Category: `General`\n- Requires restart: `yes`", "type": "string" }, "modelRouting": { From 20aa695ac4cff7bf722f00da6a86aa2593a4c55e Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Wed, 25 Mar 2026 19:59:23 +0000 Subject: [PATCH 17/49] Revert "perf(cli): optimize --version startup time (#23671)" (#23812) --- packages/cli/index.ts | 26 +++++--------------------- 1 file changed, 5 insertions(+), 21 deletions(-) diff --git a/packages/cli/index.ts b/packages/cli/index.ts index fa6537d7bf..5444fe1b74 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -6,19 +6,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -// --- Fast Path for Version --- -// We check for version flags at the very top to avoid loading any heavy dependencies. -// process.env.CLI_VERSION is defined during the build process by esbuild. -if (process.argv.includes('--version') || process.argv.includes('-v')) { - console.log(process.env['CLI_VERSION'] || 'unknown'); - process.exit(0); -} +import { main } from './src/gemini.js'; +import { FatalError, writeToStderr } from '@google/gemini-cli-core'; +import { runExitCleanup } from './src/utils/cleanup.js'; // --- Global Entry Point --- -let writeToStderrFn: (message: string) => void = (msg) => - process.stderr.write(msg); - // Suppress known race condition error in node-pty on Windows // Tracking bug: https://github.com/microsoft/node-pty/issues/827 process.on('uncaughtException', (error) => { @@ -35,22 +28,13 @@ process.on('uncaughtException', (error) => { // For other errors, we rely on the default behavior, but since we attached a listener, // we must manually replicate it. if (error instanceof Error) { - writeToStderrFn(error.stack + '\n'); + writeToStderr(error.stack + '\n'); } else { - writeToStderrFn(String(error) + '\n'); + writeToStderr(String(error) + '\n'); } process.exit(1); }); -const [{ main }, { FatalError, writeToStderr }, { runExitCleanup }] = - await Promise.all([ - import('./src/gemini.js'), - import('@google/gemini-cli-core'), - import('./src/utils/cleanup.js'), - ]); - -writeToStderrFn = writeToStderr; - main().catch(async (error) => { // Set a timeout to force exit if cleanup hangs const cleanupTimeout = setTimeout(() => { From a6a36892989492a7af2a7db4de793437c41da88d Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Wed, 25 Mar 2026 20:38:30 +0000 Subject: [PATCH 18/49] don't silence errors from wombat (#23822) --- .github/actions/publish-release/action.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/actions/publish-release/action.yml b/.github/actions/publish-release/action.yml index 54c404c7c1..a9e33f36eb 100644 --- a/.github/actions/publish-release/action.yml +++ b/.github/actions/publish-release/action.yml @@ -175,7 +175,7 @@ runs: --dry-run="${INPUTS_DRY_RUN}" \ --workspace="${INPUTS_CORE_PACKAGE_NAME}" \ --no-tag - npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} false --silent + npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} false - name: '🔗 Install latest core package' working-directory: '${{ inputs.working-directory }}' @@ -221,7 +221,7 @@ runs: --dry-run="${INPUTS_DRY_RUN}" \ --workspace="${INPUTS_CLI_PACKAGE_NAME}" \ --no-tag - npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} false --silent + npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} false - name: 'Get a2a-server Token' uses: './.github/actions/npm-auth-token' @@ -246,7 +246,7 @@ runs: --dry-run="${INPUTS_DRY_RUN}" \ --workspace="${INPUTS_A2A_PACKAGE_NAME}" \ --no-tag - npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} false --silent + npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} false - name: '🔬 Verify NPM release by version' uses: './.github/actions/verify-release' From fd0893c346929f6c2be1d06bafce30b1c1230ce2 Mon Sep 17 00:00:00 2001 From: Prasanna Pal Date: Thu, 26 Mar 2026 01:55:13 +0530 Subject: [PATCH 19/49] fix(ui): prevent escape key from cancelling requests in shell mode (#21245) --- .../src/ui/components/InputPrompt.test.tsx | 62 ++++++++++++++++++- .../cli/src/ui/components/InputPrompt.tsx | 16 ++--- .../src/ui/contexts/KeypressContext.test.tsx | 43 +++++++++++++ 3 files changed, 113 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 330faec022..e9f4efcd8f 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -61,7 +61,7 @@ import type { UIState } from '../contexts/UIStateContext.js'; import { isLowColorDepth } from '../utils/terminalUtils.js'; import { cpLen } from '../utils/textUtils.js'; import { defaultKeyMatchers, Command } from '../key/keyMatchers.js'; -import type { Key } from '../hooks/useKeypress.js'; +import { useKeypress, type Key } from '../hooks/useKeypress.js'; import { appEvents, AppEvent, @@ -163,6 +163,18 @@ describe('InputPrompt', () => { let mockBuffer: TextBuffer; let mockCommandContext: CommandContext; + const GlobalEscapeHandler = ({ onEscape }: { onEscape: () => void }) => { + useKeypress( + (key) => { + if (key.name !== 'escape') return false; + onEscape(); + return true; + }, + { isActive: true, priority: false }, + ); + return null; + }; + const mockedUseShellHistory = vi.mocked(useShellHistory); const mockedUseCommandCompletion = vi.mocked(useCommandCompletion); const mockedUseInputHistory = vi.mocked(useInputHistory); @@ -2770,6 +2782,54 @@ describe('InputPrompt', () => { unmount(); }); + it('should not propagate ESC to global cancellation handler when shell mode is active (responding)', async () => { + props.shellModeActive = true; + props.streamingState = StreamingState.Responding; + const onGlobalEscape = vi.fn(); + + const { stdin, unmount } = await renderWithProviders( + <> + + + , + ); + + await act(async () => { + stdin.write('\x1B'); + vi.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(props.setShellModeActive).toHaveBeenCalledWith(false); + }); + expect(onGlobalEscape).not.toHaveBeenCalled(); + unmount(); + }); + + it('should allow ESC to reach global cancellation handler when responding and no overlay is active', async () => { + props.shellModeActive = false; + props.streamingState = StreamingState.Responding; + const onGlobalEscape = vi.fn(); + + const { stdin, unmount } = await renderWithProviders( + <> + + + , + ); + + await act(async () => { + stdin.write('\x1B'); + vi.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(onGlobalEscape).toHaveBeenCalledTimes(1); + }); + expect(props.setShellModeActive).not.toHaveBeenCalled(); + unmount(); + }); + it('should handle ESC when completion suggestions are showing', async () => { mockedUseCommandCompletion.mockReturnValue({ ...mockCommandCompletion, diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 35cf7ef656..e7c221579a 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -686,13 +686,9 @@ export const InputPrompt: React.FC = ({ return true; } - if ( - key.name === 'escape' && - (streamingState === StreamingState.Responding || - streamingState === StreamingState.WaitingForConfirmation) - ) { - return false; - } + const isGenerating = + streamingState === StreamingState.Responding || + streamingState === StreamingState.WaitingForConfirmation; const isPlainTab = key.name === 'tab' && !key.shift && !key.alt && !key.ctrl && !key.cmd; @@ -877,6 +873,12 @@ export const InputPrompt: React.FC = ({ return true; } + // If we're generating and no local overlay consumed Escape, let it + // propagate to the global cancellation handler. + if (isGenerating) { + return false; + } + handleEscPress(); return true; } diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index c2256ed5ae..e7d0406dd7 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -14,6 +14,7 @@ import { useKeypressContext, ESC_TIMEOUT, FAST_RETURN_TIMEOUT, + KeypressPriority, type Key, } from './KeypressContext.js'; import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js'; @@ -259,6 +260,48 @@ describe('KeypressContext', () => { ); }); + it('should stop propagation when a higher priority handler returns true', async () => { + const higherPriorityHandler = vi.fn(() => true); + const lowerPriorityHandler = vi.fn(); + const { result } = await renderHookWithProviders(() => + useKeypressContext(), + ); + + act(() => { + result.current.subscribe(higherPriorityHandler, KeypressPriority.High); + result.current.subscribe(lowerPriorityHandler, KeypressPriority.Normal); + }); + + act(() => stdin.write('\x1b[27u')); + + expect(higherPriorityHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'escape' }), + ); + expect(lowerPriorityHandler).not.toHaveBeenCalled(); + }); + + it('should continue propagation when a higher priority handler does not consume the event', async () => { + const higherPriorityHandler = vi.fn(() => false); + const lowerPriorityHandler = vi.fn(); + const { result } = await renderHookWithProviders(() => + useKeypressContext(), + ); + + act(() => { + result.current.subscribe(higherPriorityHandler, KeypressPriority.High); + result.current.subscribe(lowerPriorityHandler, KeypressPriority.Normal); + }); + + act(() => stdin.write('\x1b[27u')); + + expect(higherPriorityHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'escape' }), + ); + expect(lowerPriorityHandler).toHaveBeenCalledWith( + expect.objectContaining({ name: 'escape' }), + ); + }); + it('should handle double Escape', async () => { const keyHandler = vi.fn(); const { result } = await renderHookWithProviders(() => From 012740b68f6701e9f727df763a3ba6727b17614f Mon Sep 17 00:00:00 2001 From: gemini-cli-robot Date: Wed, 25 Mar 2026 13:25:50 -0700 Subject: [PATCH 20/49] Changelog for v0.36.0-preview.0 (#23702) Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com> Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com> --- docs/changelogs/preview.md | 715 ++++++++++++++++++------------------- 1 file changed, 353 insertions(+), 362 deletions(-) diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index 0172fcdb87..13887112d9 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: v0.35.0-preview.5 +# Preview release: v0.36.0-preview.0 -Released: March 23, 2026 +Released: March 24, 2026 Our preview release includes the latest, new, and experimental features. This release may not be as stable as our [latest weekly release](latest.md). @@ -13,375 +13,366 @@ npm install -g @google/gemini-cli@preview ## Highlights -- **Subagents & Architecture Enhancements**: Enabled subagents and laid the - foundation for subagent tool isolation. Added proxy routing support for remote - A2A subagents and integrated `SandboxManager` to sandbox all process-spawning - tools. -- **CLI & UI Improvements**: Introduced customizable keyboard shortcuts and - support for literal character keybindings. Added missing vim mode motions and - CJK input support. Enabled code splitting and deferred UI loading for improved - performance. -- **Context & Tools Optimization**: JIT context loading is now enabled by - default with deduplication for project memory. Introduced a model-driven - parallel tool scheduler and allowed safe tools to execute concurrently. -- **Security & Extensions**: Implemented cryptographic integrity verification - for extension updates and added a `disableAlwaysAllow` setting to prevent - auto-approvals for enhanced security. -- **Plan Mode & Web Fetch Updates**: Added an 'All the above' option for - multi-select AskUser questions in Plan Mode. Rolled out Stage 1 and Stage 2 - security and consistency improvements for the `web_fetch` tool. +- **Subagent Architecture Enhancements:** Significant updates to subagents, + including local execution, tool isolation, multi-registry discovery, dynamic + tool filtering, and JIT context injection. +- **Enhanced Security & Sandboxing:** Implemented strict macOS sandboxing using + Seatbelt allowlist, native Windows sandboxing, and support for + "Write-Protected" governance files. +- **Agent Context & State Management:** Introduced task tracker protocol + integration, 'blocked' statuses for tasks/todos, and `AgentSession` for + improved state management and replay semantics. +- **Browser & ACP Capabilities:** Added privacy consent for the browser agent, + sensitive action controls, improved API token usage metadata, and gateway auth + support via ACP. +- **CLI & UX Improvements:** Implemented a refreshed Composer layout, expanded + terminal fallback warnings, dynamic model resolution, and Git worktree support + for isolated parallel sessions. ## What's Changed -- fix(patch): cherry-pick b2d6dc4 to release/v0.35.0-preview.4-pr-23546 - [CONFLICTS] by @gemini-cli-robot in - [#23585](https://github.com/google-gemini/gemini-cli/pull/23585) -- fix(patch): cherry-pick daf3691 to release/v0.35.0-preview.2-pr-23558 to patch - version v0.35.0-preview.2 and create version 0.35.0-preview.3 by - @gemini-cli-robot in - [#23565](https://github.com/google-gemini/gemini-cli/pull/23565) -- fix(patch): cherry-pick 4e5dfd0 to release/v0.35.0-preview.1-pr-23074 to patch - version v0.35.0-preview.1 and create version 0.35.0-preview.2 by - @gemini-cli-robot in - [#23134](https://github.com/google-gemini/gemini-cli/pull/23134) -- feat(cli): customizable keyboard shortcuts by @scidomino in - [#21945](https://github.com/google-gemini/gemini-cli/pull/21945) -- feat(core): Thread `AgentLoopContext` through core. by @joshualitt in - [#21944](https://github.com/google-gemini/gemini-cli/pull/21944) -- chore(release): bump version to 0.35.0-nightly.20260311.657f19c1f by - @gemini-cli-robot in - [#21966](https://github.com/google-gemini/gemini-cli/pull/21966) -- refactor(a2a): remove legacy CoreToolScheduler by @adamfweidman in - [#21955](https://github.com/google-gemini/gemini-cli/pull/21955) -- feat(ui): add missing vim mode motions (X, ~, r, f/F/t/T, df/dt and friends) - by @aanari in [#21932](https://github.com/google-gemini/gemini-cli/pull/21932) -- Feat/retry fetch notifications by @aishaneeshah in - [#21813](https://github.com/google-gemini/gemini-cli/pull/21813) -- fix(core): remove OAuth check from handleFallback and clean up stray file by - @sehoon38 in [#21962](https://github.com/google-gemini/gemini-cli/pull/21962) -- feat(cli): support literal character keybindings and extended Kitty protocol - keys by @scidomino in - [#21972](https://github.com/google-gemini/gemini-cli/pull/21972) -- fix(ui): clamp cursor to last char after all NORMAL mode deletes by @aanari in - [#21973](https://github.com/google-gemini/gemini-cli/pull/21973) -- test(core): add missing tests for prompts/utils.ts by @krrishverma1805-web in - [#19941](https://github.com/google-gemini/gemini-cli/pull/19941) -- fix(cli): allow scrolling keys in copy mode (Ctrl+S selection mode) by - @nsalerni in [#19933](https://github.com/google-gemini/gemini-cli/pull/19933) -- docs(cli): add custom keybinding documentation by @scidomino in - [#21980](https://github.com/google-gemini/gemini-cli/pull/21980) -- docs: fix misleading YOLO mode description in defaultApprovalMode by - @Gyanranjan-Priyam in - [#21878](https://github.com/google-gemini/gemini-cli/pull/21878) -- fix: clean up /clear and /resume by @jackwotherspoon in - [#22007](https://github.com/google-gemini/gemini-cli/pull/22007) -- fix(core)#20941: reap orphaned descendant processes on PTY abort by @manavmax - in [#21124](https://github.com/google-gemini/gemini-cli/pull/21124) -- fix(core): update language detection to use LSP 3.18 identifiers by @yunaseoul - in [#21931](https://github.com/google-gemini/gemini-cli/pull/21931) -- feat(cli): support removing keybindings via '-' prefix by @scidomino in - [#22042](https://github.com/google-gemini/gemini-cli/pull/22042) -- feat(policy): add --admin-policy flag for supplemental admin policies by - @galz10 in [#20360](https://github.com/google-gemini/gemini-cli/pull/20360) -- merge duplicate imports packages/cli/src subtask1 by @Nixxx19 in - [#22040](https://github.com/google-gemini/gemini-cli/pull/22040) -- perf(core): parallelize user quota and experiments fetching in refreshAuth by - @sehoon38 in [#21648](https://github.com/google-gemini/gemini-cli/pull/21648) -- Changelog for v0.34.0-preview.0 by @gemini-cli-robot in - [#21965](https://github.com/google-gemini/gemini-cli/pull/21965) -- Changelog for v0.33.0 by @gemini-cli-robot in - [#21967](https://github.com/google-gemini/gemini-cli/pull/21967) -- fix(core): handle EISDIR in robustRealpath on Windows by @sehoon38 in - [#21984](https://github.com/google-gemini/gemini-cli/pull/21984) -- feat(core): include initiationMethod in conversation interaction telemetry by - @yunaseoul in [#22054](https://github.com/google-gemini/gemini-cli/pull/22054) -- feat(ui): add vim yank/paste (y/p/P) with unnamed register by @aanari in - [#22026](https://github.com/google-gemini/gemini-cli/pull/22026) -- fix(core): enable numerical routing for api key users by @sehoon38 in - [#21977](https://github.com/google-gemini/gemini-cli/pull/21977) -- feat(telemetry): implement retry attempt telemetry for network related retries - by @aishaneeshah in - [#22027](https://github.com/google-gemini/gemini-cli/pull/22027) -- fix(policy): remove unnecessary escapeRegex from pattern builders by - @spencer426 in - [#21921](https://github.com/google-gemini/gemini-cli/pull/21921) -- fix(core): preserve dynamic tool descriptions on session resume by @sehoon38 - in [#18835](https://github.com/google-gemini/gemini-cli/pull/18835) -- chore: allow 'gemini-3.1' in sensitive keyword linter by @scidomino in - [#22065](https://github.com/google-gemini/gemini-cli/pull/22065) -- feat(core): support custom base URL via env vars by @junaiddshaukat in - [#21561](https://github.com/google-gemini/gemini-cli/pull/21561) -- merge duplicate imports packages/cli/src subtask2 by @Nixxx19 in - [#22051](https://github.com/google-gemini/gemini-cli/pull/22051) -- fix(core): silently retry API errors up to 3 times before halting session by - @spencer426 in - [#21989](https://github.com/google-gemini/gemini-cli/pull/21989) -- feat(core): simplify subagent success UI and improve early termination display - by @abhipatel12 in - [#21917](https://github.com/google-gemini/gemini-cli/pull/21917) -- merge duplicate imports packages/cli/src subtask3 by @Nixxx19 in - [#22056](https://github.com/google-gemini/gemini-cli/pull/22056) -- fix(hooks): fix BeforeAgent/AfterAgent inconsistencies (#18514) by @krishdef7 - in [#21383](https://github.com/google-gemini/gemini-cli/pull/21383) -- feat(core): implement SandboxManager interface and config schema by @galz10 in - [#21774](https://github.com/google-gemini/gemini-cli/pull/21774) -- docs: document npm deprecation warnings as safe to ignore by @h30s in - [#20692](https://github.com/google-gemini/gemini-cli/pull/20692) -- fix: remove status/need-triage from maintainer-only issues by @SandyTao520 in - [#22044](https://github.com/google-gemini/gemini-cli/pull/22044) -- fix(core): propagate subagent context to policy engine by @NTaylorMullen in - [#22086](https://github.com/google-gemini/gemini-cli/pull/22086) -- fix(cli): resolve skill uninstall failure when skill name is updated by - @NTaylorMullen in - [#22085](https://github.com/google-gemini/gemini-cli/pull/22085) -- docs(plan): clarify interactive plan editing with Ctrl+X by @Adib234 in - [#22076](https://github.com/google-gemini/gemini-cli/pull/22076) -- fix(policy): ensure user policies are loaded when policyPaths is empty by - @NTaylorMullen in - [#22090](https://github.com/google-gemini/gemini-cli/pull/22090) -- Docs: Add documentation for model steering (experimental). by @jkcinouye in - [#21154](https://github.com/google-gemini/gemini-cli/pull/21154) -- Add issue for automated changelogs by @g-samroberts in - [#21912](https://github.com/google-gemini/gemini-cli/pull/21912) -- fix(core): secure argsPattern and revert WEB_FETCH_TOOL_NAME escalation by - @spencer426 in - [#22104](https://github.com/google-gemini/gemini-cli/pull/22104) -- feat(core): differentiate User-Agent for a2a-server and ACP clients by - @bdmorgan in [#22059](https://github.com/google-gemini/gemini-cli/pull/22059) -- refactor(core): extract ExecutionLifecycleService for tool backgrounding by - @adamfweidman in - [#21717](https://github.com/google-gemini/gemini-cli/pull/21717) -- feat: Display pending and confirming tool calls by @sripasg in - [#22106](https://github.com/google-gemini/gemini-cli/pull/22106) -- feat(browser): implement input blocker overlay during automation by - @kunal-10-cloud in - [#21132](https://github.com/google-gemini/gemini-cli/pull/21132) -- fix: register themes on extension load not start by @jackwotherspoon in - [#22148](https://github.com/google-gemini/gemini-cli/pull/22148) -- feat(ui): Do not show Ultra users /upgrade hint (#22154) by @sehoon38 in - [#22156](https://github.com/google-gemini/gemini-cli/pull/22156) -- chore: remove unnecessary log for themes by @jackwotherspoon in - [#22165](https://github.com/google-gemini/gemini-cli/pull/22165) -- fix(core): resolve MCP tool FQN validation, schema export, and wildcards in - subagents by @abhipatel12 in - [#22069](https://github.com/google-gemini/gemini-cli/pull/22069) -- fix(cli): validate --model argument at startup by @JaisalJain in - [#21393](https://github.com/google-gemini/gemini-cli/pull/21393) -- fix(core): handle policy ALLOW for exit_plan_mode by @backnotprop in - [#21802](https://github.com/google-gemini/gemini-cli/pull/21802) -- feat(telemetry): add Clearcut instrumentation for AI credits billing events by - @gsquared94 in - [#22153](https://github.com/google-gemini/gemini-cli/pull/22153) -- feat(core): add google credentials provider for remote agents by @adamfweidman - in [#21024](https://github.com/google-gemini/gemini-cli/pull/21024) -- test(cli): add integration test for node deprecation warnings by @Nixxx19 in - [#20215](https://github.com/google-gemini/gemini-cli/pull/20215) -- feat(cli): allow safe tools to execute concurrently while agent is busy by - @spencer426 in - [#21988](https://github.com/google-gemini/gemini-cli/pull/21988) -- feat(core): implement model-driven parallel tool scheduler by @abhipatel12 in - [#21933](https://github.com/google-gemini/gemini-cli/pull/21933) -- update vulnerable deps by @scidomino in - [#22180](https://github.com/google-gemini/gemini-cli/pull/22180) -- fix(core): fix startup stats to use int values for timestamps and durations by - @yunaseoul in [#22201](https://github.com/google-gemini/gemini-cli/pull/22201) -- fix(core): prevent duplicate tool schemas for instantiated tools by - @abhipatel12 in - [#22204](https://github.com/google-gemini/gemini-cli/pull/22204) -- fix(core): add proxy routing support for remote A2A subagents by @adamfweidman - in [#22199](https://github.com/google-gemini/gemini-cli/pull/22199) -- fix(core/ide): add Antigravity CLI fallbacks by @apfine in - [#22030](https://github.com/google-gemini/gemini-cli/pull/22030) -- fix(browser): fix duplicate function declaration error in browser agent by - @gsquared94 in - [#22207](https://github.com/google-gemini/gemini-cli/pull/22207) -- feat(core): implement Stage 1 improvements for webfetch tool by @aishaneeshah - in [#21313](https://github.com/google-gemini/gemini-cli/pull/21313) -- Changelog for v0.34.0-preview.1 by @gemini-cli-robot in - [#22194](https://github.com/google-gemini/gemini-cli/pull/22194) -- perf(cli): enable code splitting and deferred UI loading by @sehoon38 in - [#22117](https://github.com/google-gemini/gemini-cli/pull/22117) -- fix: remove unused img.png from project root by @SandyTao520 in - [#22222](https://github.com/google-gemini/gemini-cli/pull/22222) -- docs(local model routing): add docs on how to use Gemma for local model - routing by @douglas-reid in - [#21365](https://github.com/google-gemini/gemini-cli/pull/21365) -- feat(a2a): enable native gRPC support and protocol routing by @alisa-alisa in - [#21403](https://github.com/google-gemini/gemini-cli/pull/21403) -- fix(cli): escape @ symbols on paste to prevent unintended file expansion by - @krishdef7 in [#21239](https://github.com/google-gemini/gemini-cli/pull/21239) -- feat(core): add trajectoryId to ConversationOffered telemetry by @yunaseoul in - [#22214](https://github.com/google-gemini/gemini-cli/pull/22214) -- docs: clarify that tools.core is an allowlist for ALL built-in tools by - @hobostay in [#18813](https://github.com/google-gemini/gemini-cli/pull/18813) -- docs(plan): document hooks with plan mode by @ruomengz in - [#22197](https://github.com/google-gemini/gemini-cli/pull/22197) -- Changelog for v0.33.1 by @gemini-cli-robot in - [#22235](https://github.com/google-gemini/gemini-cli/pull/22235) -- build(ci): fix false positive evals trigger on merge commits by @gundermanc in - [#22237](https://github.com/google-gemini/gemini-cli/pull/22237) -- fix(core): explicitly pass messageBus to policy engine for MCP tool saves by - @abhipatel12 in - [#22255](https://github.com/google-gemini/gemini-cli/pull/22255) -- feat(core): Fully migrate packages/core to AgentLoopContext. by @joshualitt in - [#22115](https://github.com/google-gemini/gemini-cli/pull/22115) -- feat(core): increase sub-agent turn and time limits by @bdmorgan in - [#22196](https://github.com/google-gemini/gemini-cli/pull/22196) -- feat(core): instrument file system tools for JIT context discovery by +- Changelog for v0.33.2 by @gemini-cli-robot in + [#22730](https://github.com/google-gemini/gemini-cli/pull/22730) +- feat(core): multi-registry architecture and tool filtering for subagents by + @akh64bit in [#22712](https://github.com/google-gemini/gemini-cli/pull/22712) +- Changelog for v0.34.0-preview.4 by @gemini-cli-robot in + [#22752](https://github.com/google-gemini/gemini-cli/pull/22752) +- fix(devtools): use theme-aware text colors for console warnings and errors by @SandyTao520 in - [#22082](https://github.com/google-gemini/gemini-cli/pull/22082) -- refactor(ui): extract pure session browser utilities by @abhipatel12 in - [#22256](https://github.com/google-gemini/gemini-cli/pull/22256) -- fix(plan): Fix AskUser evals by @Adib234 in - [#22074](https://github.com/google-gemini/gemini-cli/pull/22074) -- fix(settings): prevent j/k navigation keys from intercepting edit buffer input - by @student-ankitpandit in - [#21865](https://github.com/google-gemini/gemini-cli/pull/21865) -- feat(skills): improve async-pr-review workflow and logging by @mattKorwel in - [#21790](https://github.com/google-gemini/gemini-cli/pull/21790) -- refactor(cli): consolidate getErrorMessage utility to core by @scidomino in - [#22190](https://github.com/google-gemini/gemini-cli/pull/22190) -- fix(core): show descriptive error messages when saving settings fails by - @afarber in [#18095](https://github.com/google-gemini/gemini-cli/pull/18095) -- docs(core): add authentication guide for remote subagents by @adamfweidman in - [#22178](https://github.com/google-gemini/gemini-cli/pull/22178) -- docs: overhaul subagents documentation and add /agents command by @abhipatel12 - in [#22345](https://github.com/google-gemini/gemini-cli/pull/22345) -- refactor(ui): extract SessionBrowser static ui components by @abhipatel12 in - [#22348](https://github.com/google-gemini/gemini-cli/pull/22348) -- test: add Object.create context regression test and tool confirmation - integration test by @gsquared94 in - [#22356](https://github.com/google-gemini/gemini-cli/pull/22356) -- feat(tracker): return TodoList display for tracker tools by @anj-s in - [#22060](https://github.com/google-gemini/gemini-cli/pull/22060) -- feat(agent): add allowed domain restrictions for browser agent by + [#22181](https://github.com/google-gemini/gemini-cli/pull/22181) +- Add support for dynamic model Resolution to ModelConfigService by @kevinjwang1 + in [#22578](https://github.com/google-gemini/gemini-cli/pull/22578) +- chore(release): bump version to 0.36.0-nightly.20260317.2f90b4653 by + @gemini-cli-robot in + [#22858](https://github.com/google-gemini/gemini-cli/pull/22858) +- fix(cli): use active sessionId in useLogger and improve resume robustness by + @mattKorwel in + [#22606](https://github.com/google-gemini/gemini-cli/pull/22606) +- fix(cli): expand tilde in policy paths from settings.json by @abhipatel12 in + [#22772](https://github.com/google-gemini/gemini-cli/pull/22772) +- fix(core): add actionable warnings for terminal fallbacks (#14426) by + @spencer426 in + [#22211](https://github.com/google-gemini/gemini-cli/pull/22211) +- feat(tracker): integrate task tracker protocol into core system prompt by + @anj-s in [#22442](https://github.com/google-gemini/gemini-cli/pull/22442) +- chore: add posttest build hooks and fix missing dependencies by @NTaylorMullen + in [#22865](https://github.com/google-gemini/gemini-cli/pull/22865) +- feat(a2a): add agent acknowledgment command and enhance registry discovery by + @alisa-alisa in + [#22389](https://github.com/google-gemini/gemini-cli/pull/22389) +- fix(cli): automatically add all VSCode workspace folders to Gemini context by + @sakshisemalti in + [#21380](https://github.com/google-gemini/gemini-cli/pull/21380) +- feat: add 'blocked' status to tasks and todos by @anj-s in + [#22735](https://github.com/google-gemini/gemini-cli/pull/22735) +- refactor(cli): remove extra newlines in ShellToolMessage.tsx by @NTaylorMullen + in [#22868](https://github.com/google-gemini/gemini-cli/pull/22868) +- fix(cli): lazily load settings in onModelChange to prevent stale closure data + loss by @KumarADITHYA123 in + [#20403](https://github.com/google-gemini/gemini-cli/pull/20403) +- feat(core): subagent local execution and tool isolation by @akh64bit in + [#22718](https://github.com/google-gemini/gemini-cli/pull/22718) +- fix(cli): resolve subagent grouping and UI state persistence by @abhipatel12 + in [#22252](https://github.com/google-gemini/gemini-cli/pull/22252) +- refactor(ui): extract SessionBrowser search and navigation components by + @abhipatel12 in + [#22377](https://github.com/google-gemini/gemini-cli/pull/22377) +- fix: updates Docker image reference for GitHub MCP server by @jhhornn in + [#22938](https://github.com/google-gemini/gemini-cli/pull/22938) +- refactor(cli): group subagent trajectory deletion and use native filesystem + testing by @abhipatel12 in + [#22890](https://github.com/google-gemini/gemini-cli/pull/22890) +- refactor(cli): simplify keypress and mouse providers and update tests by + @scidomino in [#22853](https://github.com/google-gemini/gemini-cli/pull/22853) +- Changelog for v0.34.0 by @gemini-cli-robot in + [#22860](https://github.com/google-gemini/gemini-cli/pull/22860) +- test(cli): simplify createMockSettings calls by @scidomino in + [#22952](https://github.com/google-gemini/gemini-cli/pull/22952) +- feat(ui): format multi-line banner warnings with a bold title by @keithguerin + in [#22955](https://github.com/google-gemini/gemini-cli/pull/22955) +- Docs: Remove references to stale Gemini CLI file structure info by + @g-samroberts in + [#22976](https://github.com/google-gemini/gemini-cli/pull/22976) +- feat(ui): remove write todo list tool from UI tips by @aniruddhaadak80 in + [#22281](https://github.com/google-gemini/gemini-cli/pull/22281) +- Fix issue where subagent thoughts are appended. by @gundermanc in + [#22975](https://github.com/google-gemini/gemini-cli/pull/22975) +- Feat/browser privacy consent by @kunal-10-cloud in + [#21119](https://github.com/google-gemini/gemini-cli/pull/21119) +- fix(core): explicitly map execution context in LocalAgentExecutor by @akh64bit + in [#22949](https://github.com/google-gemini/gemini-cli/pull/22949) +- feat(plan): support plan mode in non-interactive mode by @ruomengz in + [#22670](https://github.com/google-gemini/gemini-cli/pull/22670) +- feat(core): implement strict macOS sandboxing using Seatbelt allowlist by + @ehedlund in [#22832](https://github.com/google-gemini/gemini-cli/pull/22832) +- docs: add additional notes by @abhipatel12 in + [#23008](https://github.com/google-gemini/gemini-cli/pull/23008) +- fix(cli): resolve duplicate footer on tool cancel via ESC (#21743) by + @ruomengz in [#21781](https://github.com/google-gemini/gemini-cli/pull/21781) +- Changelog for v0.35.0-preview.1 by @gemini-cli-robot in + [#23012](https://github.com/google-gemini/gemini-cli/pull/23012) +- fix(ui): fix flickering on small terminal heights by @devr0306 in + [#21416](https://github.com/google-gemini/gemini-cli/pull/21416) +- fix(acp): provide more meta in tool_call_update by @Mervap in + [#22663](https://github.com/google-gemini/gemini-cli/pull/22663) +- docs: add FAQ entry for checking Gemini CLI version by @surajsahani in + [#21271](https://github.com/google-gemini/gemini-cli/pull/21271) +- feat(core): resilient subagent tool rejection with contextual feedback by + @abhipatel12 in + [#22951](https://github.com/google-gemini/gemini-cli/pull/22951) +- fix(cli): correctly handle auto-update for standalone binaries by @bdmorgan in + [#23038](https://github.com/google-gemini/gemini-cli/pull/23038) +- feat(core): add content-utils by @adamfweidman in + [#22984](https://github.com/google-gemini/gemini-cli/pull/22984) +- fix: circumvent genai sdk requirement for api key when using gateway auth via + ACP by @sripasg in + [#23042](https://github.com/google-gemini/gemini-cli/pull/23042) +- fix(core): don't persist browser consent sentinel in non-interactive mode by + @jasonmatthewsuhari in + [#23073](https://github.com/google-gemini/gemini-cli/pull/23073) +- fix(core): narrow browser agent description to prevent stealing URL tasks from + web_fetch by @gsquared94 in + [#23086](https://github.com/google-gemini/gemini-cli/pull/23086) +- feat(cli): Partial threading of AgentLoopContext. by @joshualitt in + [#22978](https://github.com/google-gemini/gemini-cli/pull/22978) +- fix(browser-agent): enable "Allow all server tools" session policy by @cynthialong0-0 in - [#21775](https://github.com/google-gemini/gemini-cli/pull/21775) -- chore/release: bump version to 0.35.0-nightly.20260313.bb060d7a9 by - @gemini-cli-robot in - [#22251](https://github.com/google-gemini/gemini-cli/pull/22251) -- Move keychain fallback to keychain service by @chrstnb in - [#22332](https://github.com/google-gemini/gemini-cli/pull/22332) -- feat(core): integrate SandboxManager to sandbox all process-spawning tools by - @galz10 in [#22231](https://github.com/google-gemini/gemini-cli/pull/22231) -- fix(cli): support CJK input and full Unicode scalar values in terminal - protocols by @scidomino in - [#22353](https://github.com/google-gemini/gemini-cli/pull/22353) -- Promote stable tests. by @gundermanc in - [#22253](https://github.com/google-gemini/gemini-cli/pull/22253) -- feat(tracker): add tracker policy by @anj-s in - [#22379](https://github.com/google-gemini/gemini-cli/pull/22379) -- feat(security): add disableAlwaysAllow setting to disable auto-approvals by - @galz10 in [#21941](https://github.com/google-gemini/gemini-cli/pull/21941) -- Revert "fix(cli): validate --model argument at startup" by @sehoon38 in - [#22378](https://github.com/google-gemini/gemini-cli/pull/22378) -- fix(mcp): handle equivalent root resource URLs in OAuth validation by @galz10 - in [#20231](https://github.com/google-gemini/gemini-cli/pull/20231) -- fix(core): use session-specific temp directory for task tracker by @anj-s in - [#22382](https://github.com/google-gemini/gemini-cli/pull/22382) -- Fix issue where config was undefined. by @gundermanc in - [#22397](https://github.com/google-gemini/gemini-cli/pull/22397) -- fix(core): deduplicate project memory when JIT context is enabled by + [#22343](https://github.com/google-gemini/gemini-cli/pull/22343) +- refactor(cli): integrate real config loading into async test utils by + @scidomino in [#23040](https://github.com/google-gemini/gemini-cli/pull/23040) +- feat(core): inject memory and JIT context into subagents by @abhipatel12 in + [#23032](https://github.com/google-gemini/gemini-cli/pull/23032) +- Fix logging and virtual list. by @jacob314 in + [#23080](https://github.com/google-gemini/gemini-cli/pull/23080) +- feat(core): cap JIT context upward traversal at git root by @SandyTao520 in + [#23074](https://github.com/google-gemini/gemini-cli/pull/23074) +- Docs: Minor style updates from initial docs audit. by @g-samroberts in + [#22872](https://github.com/google-gemini/gemini-cli/pull/22872) +- feat(core): add experimental memory manager agent to replace save_memory tool + by @SandyTao520 in + [#22726](https://github.com/google-gemini/gemini-cli/pull/22726) +- Changelog for v0.35.0-preview.2 by @gemini-cli-robot in + [#23142](https://github.com/google-gemini/gemini-cli/pull/23142) +- Update website issue template for label and title by @g-samroberts in + [#23036](https://github.com/google-gemini/gemini-cli/pull/23036) +- fix: upgrade ACP SDK from 0.12 to 0.16.1 by @sripasg in + [#23132](https://github.com/google-gemini/gemini-cli/pull/23132) +- Update callouts to work on github. by @g-samroberts in + [#22245](https://github.com/google-gemini/gemini-cli/pull/22245) +- feat: ACP: Add token usage metadata to the `send` method's return value by + @sripasg in [#23148](https://github.com/google-gemini/gemini-cli/pull/23148) +- fix(plan): clarify that plan mode policies are combined with normal mode by + @ruomengz in [#23158](https://github.com/google-gemini/gemini-cli/pull/23158) +- Add ModelChain support to ModelConfigService and make ModelDialog dynamic by + @kevinjwang1 in + [#22914](https://github.com/google-gemini/gemini-cli/pull/22914) +- Ensure that copied extensions are writable in the user's local directory by + @kevinjwang1 in + [#23016](https://github.com/google-gemini/gemini-cli/pull/23016) +- feat(core): implement native Windows sandboxing by @mattKorwel in + [#21807](https://github.com/google-gemini/gemini-cli/pull/21807) +- feat(core): add support for admin-forced MCP server installations by + @gsquared94 in + [#23163](https://github.com/google-gemini/gemini-cli/pull/23163) +- chore(lint): ignore .gemini directory and recursive node_modules by + @mattKorwel in + [#23211](https://github.com/google-gemini/gemini-cli/pull/23211) +- feat(cli): conditionally exclude ask_user tool in ACP mode by @nmcnamara-eng + in [#23045](https://github.com/google-gemini/gemini-cli/pull/23045) +- feat(core): introduce AgentSession and rename stream events to agent events by + @mbleigh in [#23159](https://github.com/google-gemini/gemini-cli/pull/23159) +- feat(worktree): add Git worktree support for isolated parallel sessions by + @jerop in [#22973](https://github.com/google-gemini/gemini-cli/pull/22973) +- Add support for linking in the extension registry by @kevinjwang1 in + [#23153](https://github.com/google-gemini/gemini-cli/pull/23153) +- feat(extensions): add --skip-settings flag to install command by @Ratish1 in + [#17212](https://github.com/google-gemini/gemini-cli/pull/17212) +- feat(telemetry): track if session is running in a Git worktree by @jerop in + [#23265](https://github.com/google-gemini/gemini-cli/pull/23265) +- refactor(core): use absolute paths in GEMINI.md context markers by @SandyTao520 in - [#22234](https://github.com/google-gemini/gemini-cli/pull/22234) -- feat(prompts): implement Topic-Action-Summary model for verbosity reduction by - @Abhijit-2592 in - [#21503](https://github.com/google-gemini/gemini-cli/pull/21503) -- fix(core): fix manual deletion of subagent histories by @abhipatel12 in - [#22407](https://github.com/google-gemini/gemini-cli/pull/22407) -- Add registry var by @kevinjwang1 in - [#22224](https://github.com/google-gemini/gemini-cli/pull/22224) -- Add ModelDefinitions to ModelConfigService by @kevinjwang1 in - [#22302](https://github.com/google-gemini/gemini-cli/pull/22302) -- fix(cli): improve command conflict handling for skills by @NTaylorMullen in - [#21942](https://github.com/google-gemini/gemini-cli/pull/21942) -- fix(core): merge user settings with extension-provided MCP servers by + [#23135](https://github.com/google-gemini/gemini-cli/pull/23135) +- fix(core): add sanitization to sub agent thoughts and centralize utilities by + @devr0306 in [#22828](https://github.com/google-gemini/gemini-cli/pull/22828) +- feat(core): refine User-Agent for VS Code traffic (unified format) by + @sehoon38 in [#23256](https://github.com/google-gemini/gemini-cli/pull/23256) +- Fix schema for ModelChains by @kevinjwang1 in + [#23284](https://github.com/google-gemini/gemini-cli/pull/23284) +- test(cli): refactor tests for async render utilities by @scidomino in + [#23252](https://github.com/google-gemini/gemini-cli/pull/23252) +- feat(core): add security prompt for browser agent by @cynthialong0-0 in + [#23241](https://github.com/google-gemini/gemini-cli/pull/23241) +- refactor(ide): replace dynamic undici import with static fetch import by + @cocosheng-g in + [#23268](https://github.com/google-gemini/gemini-cli/pull/23268) +- test(cli): address unresolved feedback from PR #23252 by @scidomino in + [#23303](https://github.com/google-gemini/gemini-cli/pull/23303) +- feat(browser): add sensitive action controls and read-only noise reduction by + @cynthialong0-0 in + [#22867](https://github.com/google-gemini/gemini-cli/pull/22867) +- Disabling failing test while investigating by @alisa-alisa in + [#23311](https://github.com/google-gemini/gemini-cli/pull/23311) +- fix broken extension link in hooks guide by @Indrapal-70 in + [#21728](https://github.com/google-gemini/gemini-cli/pull/21728) +- fix(core): fix agent description indentation by @abhipatel12 in + [#23315](https://github.com/google-gemini/gemini-cli/pull/23315) +- Wrap the text under TOML rule for easier readability in policy-engine.md… by + @CogitationOps in + [#23076](https://github.com/google-gemini/gemini-cli/pull/23076) +- fix(extensions): revert broken extension removal behavior by @ehedlund in + [#23317](https://github.com/google-gemini/gemini-cli/pull/23317) +- feat(core): set up onboarding telemetry by @yunaseoul in + [#23118](https://github.com/google-gemini/gemini-cli/pull/23118) +- Retry evals on API error. by @gundermanc in + [#23322](https://github.com/google-gemini/gemini-cli/pull/23322) +- fix(evals): remove tool restrictions and add compile-time guards by + @SandyTao520 in + [#23312](https://github.com/google-gemini/gemini-cli/pull/23312) +- fix(hooks): support 'ask' decision for BeforeTool hooks by @gundermanc in + [#21146](https://github.com/google-gemini/gemini-cli/pull/21146) +- feat(browser): add warning message for session mode 'existing' by + @cynthialong0-0 in + [#23288](https://github.com/google-gemini/gemini-cli/pull/23288) +- chore(lint): enforce zero warnings and cleanup syntax restrictions by + @alisa-alisa in + [#22902](https://github.com/google-gemini/gemini-cli/pull/22902) +- fix(cli): add Esc instruction to HooksDialog footer by @abhipatel12 in + [#23258](https://github.com/google-gemini/gemini-cli/pull/23258) +- Disallow and suppress misused spread operator. by @gundermanc in + [#23294](https://github.com/google-gemini/gemini-cli/pull/23294) +- fix(core): refine CliHelpAgent description for better delegation by @abhipatel12 in - [#22484](https://github.com/google-gemini/gemini-cli/pull/22484) -- fix(core): skip discovery for incomplete MCP configs and resolve merge race - condition by @abhipatel12 in - [#22494](https://github.com/google-gemini/gemini-cli/pull/22494) -- fix(automation): harden stale PR closer permissions and maintainer detection - by @bdmorgan in - [#22558](https://github.com/google-gemini/gemini-cli/pull/22558) -- fix(automation): evaluate staleness before checking protected labels by - @bdmorgan in [#22561](https://github.com/google-gemini/gemini-cli/pull/22561) -- feat(agent): replace the runtime npx for browser agent chrome devtool mcp with - pre-built bundle by @cynthialong0-0 in - [#22213](https://github.com/google-gemini/gemini-cli/pull/22213) -- perf: optimize TrackerService dependency checks by @anj-s in - [#22384](https://github.com/google-gemini/gemini-cli/pull/22384) -- docs(policy): remove trailing space from commandPrefix examples by @kawasin73 - in [#22264](https://github.com/google-gemini/gemini-cli/pull/22264) -- fix(a2a-server): resolve unsafe assignment lint errors by @ehedlund in - [#22661](https://github.com/google-gemini/gemini-cli/pull/22661) -- fix: Adjust ToolGroupMessage filtering to hide Confirming and show Canceled - tool calls. by @sripasg in - [#22230](https://github.com/google-gemini/gemini-cli/pull/22230) -- Disallow Object.create() and reflect. by @gundermanc in - [#22408](https://github.com/google-gemini/gemini-cli/pull/22408) -- Guard pro model usage by @sehoon38 in - [#22665](https://github.com/google-gemini/gemini-cli/pull/22665) -- refactor(core): Creates AgentSession abstraction for consolidated agent - interface. by @mbleigh in - [#22270](https://github.com/google-gemini/gemini-cli/pull/22270) -- docs(changelog): remove internal commands from release notes by + [#23310](https://github.com/google-gemini/gemini-cli/pull/23310) +- fix(core): enable global session and persistent approval for web_fetch by + @NTaylorMullen in + [#23295](https://github.com/google-gemini/gemini-cli/pull/23295) +- fix(plan): add state transition override to prevent plan mode freeze by + @Adib234 in [#23020](https://github.com/google-gemini/gemini-cli/pull/23020) +- fix(cli): record skill activation tool calls in chat history by @NTaylorMullen + in [#23203](https://github.com/google-gemini/gemini-cli/pull/23203) +- fix(core): ensure subagent tool updates apply configuration overrides + immediately by @abhipatel12 in + [#23161](https://github.com/google-gemini/gemini-cli/pull/23161) +- fix(cli): resolve flicker at boundaries of list in BaseSelectionList by @jackwotherspoon in - [#22529](https://github.com/google-gemini/gemini-cli/pull/22529) -- feat: enable subagents by @abhipatel12 in - [#22386](https://github.com/google-gemini/gemini-cli/pull/22386) -- feat(extensions): implement cryptographic integrity verification for extension - updates by @ehedlund in - [#21772](https://github.com/google-gemini/gemini-cli/pull/21772) -- feat(tracker): polish UI sorting and formatting by @anj-s in - [#22437](https://github.com/google-gemini/gemini-cli/pull/22437) -- Changelog for v0.34.0-preview.2 by @gemini-cli-robot in - [#22220](https://github.com/google-gemini/gemini-cli/pull/22220) -- fix(core): fix three JIT context bugs in read_file, read_many_files, and - memoryDiscovery by @SandyTao520 in - [#22679](https://github.com/google-gemini/gemini-cli/pull/22679) -- refactor(core): introduce InjectionService with source-aware injection and - backend-native background completions by @adamfweidman in - [#22544](https://github.com/google-gemini/gemini-cli/pull/22544) -- Linux sandbox bubblewrap by @DavidAPierce in - [#22680](https://github.com/google-gemini/gemini-cli/pull/22680) -- feat(core): increase thought signature retry resilience by @bdmorgan in - [#22202](https://github.com/google-gemini/gemini-cli/pull/22202) -- feat(core): implement Stage 2 security and consistency improvements for - web_fetch by @aishaneeshah in - [#22217](https://github.com/google-gemini/gemini-cli/pull/22217) -- refactor(core): replace positional execute params with ExecuteOptions bag by + [#23298](https://github.com/google-gemini/gemini-cli/pull/23298) +- test(cli): force generic terminal in tests to fix snapshot failures by + @abhipatel12 in + [#23499](https://github.com/google-gemini/gemini-cli/pull/23499) +- Evals: PR Guidance adding workflow by @alisa-alisa in + [#23164](https://github.com/google-gemini/gemini-cli/pull/23164) +- feat(core): refactor SandboxManager to a stateless architecture and introduce + explicit Deny interface by @ehedlund in + [#23141](https://github.com/google-gemini/gemini-cli/pull/23141) +- feat(core): add event-translator and update agent types by @adamfweidman in + [#22985](https://github.com/google-gemini/gemini-cli/pull/22985) +- perf(cli): parallelize and background startup cleanup tasks by @sehoon38 in + [#23545](https://github.com/google-gemini/gemini-cli/pull/23545) +- fix: "allow always" for commands with paths by @scidomino in + [#23558](https://github.com/google-gemini/gemini-cli/pull/23558) +- fix(cli): prevent terminal escape sequences from leaking on exit by + @mattKorwel in + [#22682](https://github.com/google-gemini/gemini-cli/pull/22682) +- feat(cli): implement full "GEMINI CLI" logo for logged-out state by + @keithguerin in + [#22412](https://github.com/google-gemini/gemini-cli/pull/22412) +- fix(plan): reserve minimum height for selection list in AskUserDialog by + @ruomengz in [#23280](https://github.com/google-gemini/gemini-cli/pull/23280) +- fix(core): harden AgentSession replay semantics by @adamfweidman in + [#23548](https://github.com/google-gemini/gemini-cli/pull/23548) +- test(core): migrate hook tests to scheduler by @abhipatel12 in + [#23496](https://github.com/google-gemini/gemini-cli/pull/23496) +- chore(config): disable agents by default by @abhipatel12 in + [#23546](https://github.com/google-gemini/gemini-cli/pull/23546) +- fix(ui): make tool confirmations take up entire terminal height by @devr0306 + in [#22366](https://github.com/google-gemini/gemini-cli/pull/22366) +- fix(core): prevent redundant remote agent loading on model switch by @adamfweidman in - [#22674](https://github.com/google-gemini/gemini-cli/pull/22674) -- feat(config): enable JIT context loading by default by @SandyTao520 in - [#22736](https://github.com/google-gemini/gemini-cli/pull/22736) -- fix(config): ensure discoveryMaxDirs is passed to global config during - initialization by @kevin-ramdass in - [#22744](https://github.com/google-gemini/gemini-cli/pull/22744) -- fix(plan): allowlist get_internal_docs in Plan Mode by @Adib234 in - [#22668](https://github.com/google-gemini/gemini-cli/pull/22668) -- Changelog for v0.34.0-preview.3 by @gemini-cli-robot in - [#22393](https://github.com/google-gemini/gemini-cli/pull/22393) -- feat(core): add foundation for subagent tool isolation by @akh64bit in - [#22708](https://github.com/google-gemini/gemini-cli/pull/22708) -- fix(core): handle surrogate pairs in truncateString by @sehoon38 in - [#22754](https://github.com/google-gemini/gemini-cli/pull/22754) -- fix(cli): override j/k navigation in settings dialog to fix search input - conflict by @sehoon38 in - [#22800](https://github.com/google-gemini/gemini-cli/pull/22800) -- feat(plan): add 'All the above' option to multi-select AskUser questions by - @Adib234 in [#22365](https://github.com/google-gemini/gemini-cli/pull/22365) -- docs: distribute package-specific GEMINI.md context to each package by + [#23576](https://github.com/google-gemini/gemini-cli/pull/23576) +- refactor(core): update production type imports from coreToolScheduler by + @abhipatel12 in + [#23498](https://github.com/google-gemini/gemini-cli/pull/23498) +- feat(cli): always prefix extension skills with colon separator by + @NTaylorMullen in + [#23566](https://github.com/google-gemini/gemini-cli/pull/23566) +- fix(core): properly support allowRedirect in policy engine by @scidomino in + [#23579](https://github.com/google-gemini/gemini-cli/pull/23579) +- fix(cli): prevent subcommand shadowing and skip auth for commands by + @mattKorwel in + [#23177](https://github.com/google-gemini/gemini-cli/pull/23177) +- fix(test): move flaky tests to non-blocking suite by @mattKorwel in + [#23259](https://github.com/google-gemini/gemini-cli/pull/23259) +- Changelog for v0.35.0-preview.3 by @gemini-cli-robot in + [#23574](https://github.com/google-gemini/gemini-cli/pull/23574) +- feat(skills): add behavioral-evals skill with fixing and promoting guides by + @abhipatel12 in + [#23349](https://github.com/google-gemini/gemini-cli/pull/23349) +- refactor(core): delete obsolete coreToolScheduler by @abhipatel12 in + [#23502](https://github.com/google-gemini/gemini-cli/pull/23502) +- Changelog for v0.35.0-preview.4 by @gemini-cli-robot in + [#23581](https://github.com/google-gemini/gemini-cli/pull/23581) +- feat(core): add LegacyAgentSession by @adamfweidman in + [#22986](https://github.com/google-gemini/gemini-cli/pull/22986) +- feat(test-utils): add TestMcpServerBuilder and support in TestRig by + @abhipatel12 in + [#23491](https://github.com/google-gemini/gemini-cli/pull/23491) +- fix(core)!: Force policy config to specify toolName by @kschaab in + [#23330](https://github.com/google-gemini/gemini-cli/pull/23330) +- eval(save_memory): add multi-turn interactive evals for memoryManager by @SandyTao520 in - [#22734](https://github.com/google-gemini/gemini-cli/pull/22734) -- fix(cli): clean up stale pasted placeholder metadata after word/line deletions - by @Jomak-x in - [#20375](https://github.com/google-gemini/gemini-cli/pull/20375) -- refactor(core): align JIT memory placement with tiered context model by - @SandyTao520 in - [#22766](https://github.com/google-gemini/gemini-cli/pull/22766) -- Linux sandbox seccomp by @DavidAPierce in - [#22815](https://github.com/google-gemini/gemini-cli/pull/22815) + [#23572](https://github.com/google-gemini/gemini-cli/pull/23572) +- fix(telemetry): patch memory leak and enforce logPrompts privacy by + @spencer426 in + [#23281](https://github.com/google-gemini/gemini-cli/pull/23281) +- perf(cli): background IDE client to speed up initialization by @sehoon38 in + [#23603](https://github.com/google-gemini/gemini-cli/pull/23603) +- fix(cli): prevent Ctrl+D exit when input buffer is not empty by @wtanaka in + [#23306](https://github.com/google-gemini/gemini-cli/pull/23306) +- fix: ACP: separate conversational text from execute tool command title by + @sripasg in [#23179](https://github.com/google-gemini/gemini-cli/pull/23179) +- feat(evals): add behavioral evaluations for subagent routing by @Samee24 in + [#23272](https://github.com/google-gemini/gemini-cli/pull/23272) +- refactor(cli,core): foundational layout, identity management, and type safety + by @jwhelangoog in + [#23286](https://github.com/google-gemini/gemini-cli/pull/23286) +- fix(core): accurately reflect subagent tool failure in UI by @abhipatel12 in + [#23187](https://github.com/google-gemini/gemini-cli/pull/23187) +- Changelog for v0.35.0-preview.5 by @gemini-cli-robot in + [#23606](https://github.com/google-gemini/gemini-cli/pull/23606) +- feat(ui): implement refreshed UX for Composer layout by @jwhelangoog in + [#21212](https://github.com/google-gemini/gemini-cli/pull/21212) +- fix: API key input dialog user interaction when selected Gemini API Key by + @kartikangiras in + [#21057](https://github.com/google-gemini/gemini-cli/pull/21057) +- docs: update `/mcp refresh` to `/mcp reload` by @adamfweidman in + [#23631](https://github.com/google-gemini/gemini-cli/pull/23631) +- Implementation of sandbox "Write-Protected" Governance Files by @DavidAPierce + in [#23139](https://github.com/google-gemini/gemini-cli/pull/23139) +- feat(sandbox): dynamic macOS sandbox expansion and worktree support by @galz10 + in [#23301](https://github.com/google-gemini/gemini-cli/pull/23301) +- fix(acp): Pass the cwd to `AcpFileSystemService` to avoid looping failures in + asking for perms to write plan md file by @sripasg in + [#23612](https://github.com/google-gemini/gemini-cli/pull/23612) +- fix(plan): sandbox path resolution in Plan Mode to prevent hallucinations by + @Adib234 in [#22737](https://github.com/google-gemini/gemini-cli/pull/22737) +- feat(ui): allow immediate user input during startup by @sehoon38 in + [#23661](https://github.com/google-gemini/gemini-cli/pull/23661) +- refactor(sandbox): reorganize Windows sandbox files by @galz10 in + [#23645](https://github.com/google-gemini/gemini-cli/pull/23645) +- fix(core): improve remote agent streaming UI and UX by @adamfweidman in + [#23633](https://github.com/google-gemini/gemini-cli/pull/23633) +- perf(cli): optimize --version startup time by @sehoon38 in + [#23671](https://github.com/google-gemini/gemini-cli/pull/23671) +- refactor(core): stop gemini CLI from producing unsafe casts by @gundermanc in + [#23611](https://github.com/google-gemini/gemini-cli/pull/23611) +- use enableAutoUpdate in test rig by @scidomino in + [#23681](https://github.com/google-gemini/gemini-cli/pull/23681) +- feat(core): change user-facing auth type from oauth2 to oauth by @adamfweidman + in [#23639](https://github.com/google-gemini/gemini-cli/pull/23639) +- chore(deps): fix npm audit vulnerabilities by @scidomino in + [#23679](https://github.com/google-gemini/gemini-cli/pull/23679) +- test(evals): fix overlapping act() deadlock in app-test-helper by @Adib234 in + [#23666](https://github.com/google-gemini/gemini-cli/pull/23666) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.34.0-preview.4...v0.35.0-preview.5 +https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.0 From 3ada29fb519d984b197bc597f0e45b739dd43e37 Mon Sep 17 00:00:00 2001 From: christine betts Date: Wed, 25 Mar 2026 16:28:49 -0400 Subject: [PATCH 21/49] feat(core,ui): Add experiment-gated support for gemini flash 3.1 lite (#23794) --- docs/reference/configuration.md | 26 +++- packages/cli/src/config/settingsSchema.ts | 1 + .../src/ui/components/ModelDialog.test.tsx | 6 + .../cli/src/ui/components/ModelDialog.tsx | 13 +- .../cli/src/ui/components/StatsDisplay.tsx | 14 +- .../src/availability/policyCatalog.test.ts | 2 + .../core/src/availability/policyCatalog.ts | 2 + .../src/availability/policyHelpers.test.ts | 2 + .../core/src/availability/policyHelpers.ts | 6 + .../src/code_assist/experiments/flagNames.ts | 1 + packages/core/src/config/config.ts | 44 ++++++- .../core/src/config/defaultModelConfigs.ts | 20 +++ packages/core/src/config/models.test.ts | 123 +++++++++++++----- packages/core/src/config/models.ts | 26 +++- packages/core/src/core/client.ts | 1 + packages/core/src/core/contentGenerator.ts | 3 + packages/core/src/core/geminiChat.ts | 11 +- packages/core/src/prompts/promptProvider.ts | 2 + .../strategies/classifierStrategy.test.ts | 1 + .../routing/strategies/classifierStrategy.ts | 11 +- .../src/routing/strategies/defaultStrategy.ts | 1 + .../routing/strategies/fallbackStrategy.ts | 1 + .../numericalClassifierStrategy.test.ts | 1 + .../strategies/numericalClassifierStrategy.ts | 11 +- .../routing/strategies/overrideStrategy.ts | 1 + .../src/services/chatCompressionService.ts | 3 + .../core/src/services/modelConfigService.ts | 4 + .../resolved-aliases-retry.golden.json | 4 + .../test-data/resolved-aliases.golden.json | 4 + schemas/settings.schema.json | 61 ++++++++- 30 files changed, 354 insertions(+), 52 deletions(-) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 5c4ef25544..ef325681ce 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -646,6 +646,11 @@ their corresponding top-level category object in your `settings.json` file. "model": "gemini-3-flash-preview" } }, + "chat-compression-3.1-flash-lite": { + "modelConfig": { + "model": "gemini-3.1-flash-lite-preview" + } + }, "chat-compression-2.5-pro": { "modelConfig": { "model": "gemini-2.5-pro" @@ -980,6 +985,17 @@ their corresponding top-level category object in your `settings.json` file. "auto-gemini-2.5": { "default": "gemini-2.5-pro" }, + "gemini-3.1-flash-lite-preview": { + "default": "gemini-3.1-flash-lite-preview", + "contexts": [ + { + "condition": { + "useGemini3_1FlashLite": false + }, + "target": "gemini-2.5-flash-lite" + } + ] + }, "flash": { "default": "gemini-3-flash-preview", "contexts": [ @@ -992,7 +1008,15 @@ their corresponding top-level category object in your `settings.json` file. ] }, "flash-lite": { - "default": "gemini-2.5-flash-lite" + "default": "gemini-2.5-flash-lite", + "contexts": [ + { + "condition": { + "useGemini3_1FlashLite": true + }, + "target": "gemini-3.1-flash-lite-preview" + } + ] } } ``` diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 891e383bc9..aba97ca179 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -3024,6 +3024,7 @@ export const SETTINGS_SCHEMA_DEFINITIONS: Record< type: 'object', properties: { useGemini3_1: { type: 'boolean' }, + useGemini3_1FlashLite: { type: 'boolean' }, useCustomTools: { type: 'boolean' }, hasAccessToPreview: { type: 'boolean' }, requestedModels: { diff --git a/packages/cli/src/ui/components/ModelDialog.test.tsx b/packages/cli/src/ui/components/ModelDialog.test.tsx index b6921d1371..fd5df5db89 100644 --- a/packages/cli/src/ui/components/ModelDialog.test.tsx +++ b/packages/cli/src/ui/components/ModelDialog.test.tsx @@ -53,6 +53,7 @@ describe('', () => { const mockOnClose = vi.fn(); const mockGetHasAccessToPreviewModel = vi.fn(); const mockGetGemini31LaunchedSync = vi.fn(); + const mockGetGemini31FlashLiteLaunchedSync = vi.fn(); const mockGetProModelNoAccess = vi.fn(); const mockGetProModelNoAccessSync = vi.fn(); const mockGetUserTier = vi.fn(); @@ -63,6 +64,7 @@ describe('', () => { getHasAccessToPreviewModel: () => boolean; getIdeMode: () => boolean; getGemini31LaunchedSync: () => boolean; + getGemini31FlashLiteLaunchedSync: () => boolean; getProModelNoAccess: () => Promise; getProModelNoAccessSync: () => boolean; getUserTier: () => UserTierId | undefined; @@ -74,6 +76,7 @@ describe('', () => { getHasAccessToPreviewModel: mockGetHasAccessToPreviewModel, getIdeMode: () => false, getGemini31LaunchedSync: mockGetGemini31LaunchedSync, + getGemini31FlashLiteLaunchedSync: mockGetGemini31FlashLiteLaunchedSync, getProModelNoAccess: mockGetProModelNoAccess, getProModelNoAccessSync: mockGetProModelNoAccessSync, getUserTier: mockGetUserTier, @@ -84,6 +87,7 @@ describe('', () => { mockGetModel.mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO); mockGetHasAccessToPreviewModel.mockReturnValue(false); mockGetGemini31LaunchedSync.mockReturnValue(false); + mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(false); mockGetProModelNoAccess.mockResolvedValue(false); mockGetProModelNoAccessSync.mockReturnValue(false); mockGetUserTier.mockReturnValue(UserTierId.STANDARD); @@ -131,6 +135,7 @@ describe('', () => { mockGetProModelNoAccessSync.mockReturnValue(true); mockGetProModelNoAccess.mockResolvedValue(true); mockGetHasAccessToPreviewModel.mockReturnValue(true); + mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(true); mockGetUserTier.mockReturnValue(UserTierId.FREE); mockGetDisplayString.mockImplementation((val: string) => val); @@ -463,6 +468,7 @@ describe('', () => { mockGetProModelNoAccessSync.mockReturnValue(false); mockGetProModelNoAccess.mockResolvedValue(false); mockGetHasAccessToPreviewModel.mockReturnValue(true); + mockGetGemini31FlashLiteLaunchedSync.mockReturnValue(true); mockGetUserTier.mockReturnValue(UserTierId.FREE); const { lastFrame, stdin, waitUntilReady, unmount } = await renderComponent(); diff --git a/packages/cli/src/ui/components/ModelDialog.tsx b/packages/cli/src/ui/components/ModelDialog.tsx index c42838c070..0bd7918248 100644 --- a/packages/cli/src/ui/components/ModelDialog.tsx +++ b/packages/cli/src/ui/components/ModelDialog.tsx @@ -63,6 +63,8 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { const shouldShowPreviewModels = config?.getHasAccessToPreviewModel(); const useGemini31 = config?.getGemini31LaunchedSync?.() ?? false; + const useGemini31FlashLite = + config?.getGemini31FlashLiteLaunchedSync?.() ?? false; const selectedAuthType = settings.merged.security.auth.selectedType; const useCustomToolModel = useGemini31 && selectedAuthType === AuthType.USE_GEMINI; @@ -86,6 +88,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_3_1_MODEL, PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, + PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, PREVIEW_GEMINI_FLASH_MODEL, ]; if (manualModels.includes(preferredModel)) { @@ -210,7 +213,10 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { // Flag Guard: Versioned models only show if their flag is active. if (id === PREVIEW_GEMINI_3_1_MODEL && !useGemini31) return false; - if (id === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL && !useGemini31) + if ( + id === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL && + !useGemini31FlashLite + ) return false; return true; @@ -218,11 +224,13 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { .map(([id, m]) => { const resolvedId = config.modelConfigService.resolveModelId(id, { useGemini3_1: useGemini31, + useGemini3_1FlashLite: useGemini31FlashLite, useCustomTools: useCustomToolModel, }); // Title ID is the resolved ID without custom tools flag const titleId = config.modelConfigService.resolveModelId(id, { useGemini3_1: useGemini31, + useGemini3_1FlashLite: useGemini31FlashLite, }); return { value: resolvedId, @@ -284,7 +292,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { }, ]; - if (isFreeTier) { + if (isFreeTier && useGemini31FlashLite) { previewOptions.push({ value: PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, title: getDisplayString(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL), @@ -304,6 +312,7 @@ export function ModelDialog({ onClose }: ModelDialogProps): React.JSX.Element { }, [ shouldShowPreviewModels, useGemini31, + useGemini31FlashLite, useCustomToolModel, hasAccessToProModel, config, diff --git a/packages/cli/src/ui/components/StatsDisplay.tsx b/packages/cli/src/ui/components/StatsDisplay.tsx index 9effb39b5c..5e1291b97a 100644 --- a/packages/cli/src/ui/components/StatsDisplay.tsx +++ b/packages/cli/src/ui/components/StatsDisplay.tsx @@ -92,6 +92,7 @@ const buildModelRows = ( config: Config, quotas?: RetrieveUserQuotaResponse, useGemini3_1 = false, + useGemini3_1FlashLite = false, useCustomToolModel = false, ) => { const getBaseModelName = (name: string) => name.replace('-001', ''); @@ -124,7 +125,12 @@ const buildModelRows = ( ?.filter( (b) => b.modelId && - isActiveModel(b.modelId, useGemini3_1, useCustomToolModel) && + isActiveModel( + b.modelId, + useGemini3_1, + useGemini3_1FlashLite, + useCustomToolModel, + ) && !usedModelNames.has(getDisplayString(b.modelId, config)), ) .map((bucket) => ({ @@ -152,6 +158,7 @@ const ModelUsageTable: React.FC<{ pooledLimit?: number; pooledResetTime?: string; useGemini3_1?: boolean; + useGemini3_1FlashLite?: boolean; useCustomToolModel?: boolean; }> = ({ models, @@ -164,6 +171,7 @@ const ModelUsageTable: React.FC<{ pooledLimit, pooledResetTime, useGemini3_1, + useGemini3_1FlashLite, useCustomToolModel, }) => { const { stdout } = useStdout(); @@ -173,6 +181,7 @@ const ModelUsageTable: React.FC<{ config, quotas, useGemini3_1, + useGemini3_1FlashLite, useCustomToolModel, ); @@ -541,6 +550,8 @@ export const StatsDisplay: React.FC = ({ const settings = useSettings(); const config = useConfig(); const useGemini3_1 = config.getGemini31LaunchedSync?.() ?? false; + const useGemini3_1FlashLite = + config.getGemini31FlashLiteLaunchedSync?.() ?? false; const useCustomToolModel = useGemini3_1 && config.getContentGeneratorConfig().authType === AuthType.USE_GEMINI; @@ -697,6 +708,7 @@ export const StatsDisplay: React.FC = ({ pooledLimit={pooledLimit} pooledResetTime={pooledResetTime} useGemini3_1={useGemini3_1} + useGemini3_1FlashLite={useGemini3_1FlashLite} useCustomToolModel={useCustomToolModel} /> {renderFooter()} diff --git a/packages/core/src/availability/policyCatalog.test.ts b/packages/core/src/availability/policyCatalog.test.ts index 0133308688..63bca63336 100644 --- a/packages/core/src/availability/policyCatalog.test.ts +++ b/packages/core/src/availability/policyCatalog.test.ts @@ -28,6 +28,7 @@ describe('policyCatalog', () => { const chain = getModelPolicyChain({ previewEnabled: true, useGemini31: true, + useGemini31FlashLite: false, }); expect(chain[0]?.model).toBe(PREVIEW_GEMINI_3_1_MODEL); expect(chain).toHaveLength(2); @@ -38,6 +39,7 @@ describe('policyCatalog', () => { const chain = getModelPolicyChain({ previewEnabled: true, useGemini31: true, + useGemini31FlashLite: false, useCustomToolModel: true, }); expect(chain[0]?.model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL); diff --git a/packages/core/src/availability/policyCatalog.ts b/packages/core/src/availability/policyCatalog.ts index 39dea34a2f..588d9a298d 100644 --- a/packages/core/src/availability/policyCatalog.ts +++ b/packages/core/src/availability/policyCatalog.ts @@ -30,6 +30,7 @@ export interface ModelPolicyOptions { previewEnabled: boolean; userTier?: UserTierId; useGemini31?: boolean; + useGemini31FlashLite?: boolean; useCustomToolModel?: boolean; } @@ -85,6 +86,7 @@ export function getModelPolicyChain( const previewModel = resolveModel( PREVIEW_GEMINI_MODEL, options.useGemini31, + options.useGemini31FlashLite, options.useCustomToolModel, ); return [ diff --git a/packages/core/src/availability/policyHelpers.test.ts b/packages/core/src/availability/policyHelpers.test.ts index 8ec32e8292..7035fa9ed9 100644 --- a/packages/core/src/availability/policyHelpers.test.ts +++ b/packages/core/src/availability/policyHelpers.test.ts @@ -27,6 +27,7 @@ const createMockConfig = (overrides: Partial = {}): Config => { getUserTier: () => undefined, getModel: () => 'gemini-2.5-pro', getGemini31LaunchedSync: () => false, + getGemini31FlashLiteLaunchedSync: () => false, getUseCustomToolModelSync: () => { const useGemini31 = config.getGemini31LaunchedSync(); const authType = config.getContentGeneratorConfig().authType; @@ -203,6 +204,7 @@ describe('policyHelpers', () => { getExperimentalDynamicModelConfiguration: () => dynamic, getModel: () => model, getGemini31LaunchedSync: () => useGemini31 ?? false, + getGemini31FlashLiteLaunchedSync: () => false, getHasAccessToPreviewModel: () => hasAccess ?? true, getContentGeneratorConfig: () => ({ authType }), modelConfigService: new ModelConfigService(DEFAULT_MODEL_CONFIGS), diff --git a/packages/core/src/availability/policyHelpers.ts b/packages/core/src/availability/policyHelpers.ts index bd8cede300..2581a07e28 100644 --- a/packages/core/src/availability/policyHelpers.ts +++ b/packages/core/src/availability/policyHelpers.ts @@ -45,12 +45,15 @@ export function resolvePolicyChain( let chain; const useGemini31 = config.getGemini31LaunchedSync?.() ?? false; + const useGemini31FlashLite = + config.getGemini31FlashLiteLaunchedSync?.() ?? false; const useCustomToolModel = config.getUseCustomToolModelSync?.() ?? false; const hasAccessToPreview = config.getHasAccessToPreviewModel?.() ?? true; const resolvedModel = resolveModel( modelFromConfig, useGemini31, + useGemini31FlashLite, useCustomToolModel, hasAccessToPreview, config, @@ -64,6 +67,7 @@ export function resolvePolicyChain( if (config.getExperimentalDynamicModelConfiguration?.() === true) { const context = { useGemini3_1: useGemini31, + useGemini3_1FlashLite: useGemini31FlashLite, useCustomTools: useCustomToolModel, }; @@ -120,6 +124,7 @@ export function resolvePolicyChain( previewEnabled, userTier: config.getUserTier(), useGemini31, + useGemini31FlashLite, useCustomToolModel, }); } else { @@ -129,6 +134,7 @@ export function resolvePolicyChain( previewEnabled: false, userTier: config.getUserTier(), useGemini31, + useGemini31FlashLite, useCustomToolModel, }); } diff --git a/packages/core/src/code_assist/experiments/flagNames.ts b/packages/core/src/code_assist/experiments/flagNames.ts index 25dc67e845..99f2f88cc7 100644 --- a/packages/core/src/code_assist/experiments/flagNames.ts +++ b/packages/core/src/code_assist/experiments/flagNames.ts @@ -18,6 +18,7 @@ export const ExperimentFlags = { MASKING_PROTECT_LATEST_TURN: 45758819, GEMINI_3_1_PRO_LAUNCHED: 45760185, PRO_MODEL_NO_ACCESS: 45768879, + GEMINI_3_1_FLASH_LITE_LAUNCHED: 45771641, } as const; export type ExperimentFlagName = diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index a7af5387d6..e727881a04 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1820,6 +1820,10 @@ export class Config implements McpContext, AgentLoopContext { const primaryModel = resolveModel( this.getModel(), this.getGemini31LaunchedSync(), + this.getGemini31FlashLiteLaunchedSync(), + this.getUseCustomToolModelSync(), + this.getHasAccessToPreviewModel(), + this, ); return this.modelQuotas.get(primaryModel)?.remaining; } @@ -1832,6 +1836,10 @@ export class Config implements McpContext, AgentLoopContext { const primaryModel = resolveModel( this.getModel(), this.getGemini31LaunchedSync(), + this.getGemini31FlashLiteLaunchedSync(), + this.getUseCustomToolModelSync(), + this.getHasAccessToPreviewModel(), + this, ); return this.modelQuotas.get(primaryModel)?.limit; } @@ -1844,6 +1852,10 @@ export class Config implements McpContext, AgentLoopContext { const primaryModel = resolveModel( this.getModel(), this.getGemini31LaunchedSync(), + this.getGemini31FlashLiteLaunchedSync(), + this.getUseCustomToolModelSync(), + this.getHasAccessToPreviewModel(), + this, ); return this.modelQuotas.get(primaryModel)?.resetTime; } @@ -2907,7 +2919,7 @@ export class Config implements McpContext, AgentLoopContext { } /** - * Returns whether Gemini 3.1 has been launched. + * Returns whether Gemini 3.1 Pro has been launched. * This method is async and ensures that experiments are loaded before returning the result. */ async getGemini31Launched(): Promise { @@ -2915,6 +2927,15 @@ export class Config implements McpContext, AgentLoopContext { return this.getGemini31LaunchedSync(); } + /** + * Returns whether Gemini 3.1 Flash Lite has been launched. + * This method is async and ensures that experiments are loaded before returning the result. + */ + async getGemini31FlashLiteLaunched(): Promise { + await this.ensureExperimentsLoaded(); + return this.getGemini31FlashLiteLaunchedSync(); + } + /** * Returns whether the custom tool model should be used. */ @@ -2956,6 +2977,27 @@ export class Config implements McpContext, AgentLoopContext { ); } + /** + * Returns whether Gemini 3.1 Flash Lite has been launched. + * + * Note: This method should only be called after startup, once experiments have been loaded. + * If you need to call this during startup or from an async context, use + * getGemini31FlashLiteLaunched instead. + */ + getGemini31FlashLiteLaunchedSync(): boolean { + const authType = this.contentGeneratorConfig?.authType; + if ( + authType === AuthType.USE_GEMINI || + authType === AuthType.USE_VERTEX_AI + ) { + return true; + } + return ( + this.experiments?.flags[ExperimentFlags.GEMINI_3_1_FLASH_LITE_LAUNCHED] + ?.boolValue ?? false + ); + } + private async ensureExperimentsLoaded(): Promise { if (!this.experimentsPromise) { return; diff --git a/packages/core/src/config/defaultModelConfigs.ts b/packages/core/src/config/defaultModelConfigs.ts index 1ee30a8c85..62357aa733 100644 --- a/packages/core/src/config/defaultModelConfigs.ts +++ b/packages/core/src/config/defaultModelConfigs.ts @@ -218,6 +218,11 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = { model: 'gemini-3-flash-preview', }, }, + 'chat-compression-3.1-flash-lite': { + modelConfig: { + model: 'gemini-3.1-flash-lite-preview', + }, + }, 'chat-compression-2.5-pro': { modelConfig: { model: 'gemini-2.5-pro', @@ -436,6 +441,15 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = { 'auto-gemini-2.5': { default: 'gemini-2.5-pro', }, + 'gemini-3.1-flash-lite-preview': { + default: 'gemini-3.1-flash-lite-preview', + contexts: [ + { + condition: { useGemini3_1FlashLite: false }, + target: 'gemini-2.5-flash-lite', + }, + ], + }, flash: { default: 'gemini-3-flash-preview', contexts: [ @@ -447,6 +461,12 @@ export const DEFAULT_MODEL_CONFIGS: ModelConfigServiceConfig = { }, 'flash-lite': { default: 'gemini-2.5-flash-lite', + contexts: [ + { + condition: { useGemini3_1FlashLite: true }, + target: 'gemini-3.1-flash-lite-preview', + }, + ], }, }, classifierIdResolutions: { diff --git a/packages/core/src/config/models.test.ts b/packages/core/src/config/models.test.ts index 19b6d81b29..64e78789d2 100644 --- a/packages/core/src/config/models.test.ts +++ b/packages/core/src/config/models.test.ts @@ -21,6 +21,7 @@ import { supportsMultimodalFunctionResponse, GEMINI_MODEL_ALIAS_PRO, GEMINI_MODEL_ALIAS_FLASH, + GEMINI_MODEL_ALIAS_FLASH_LITE, GEMINI_MODEL_ALIAS_AUTO, PREVIEW_GEMINI_FLASH_MODEL, PREVIEW_GEMINI_MODEL_AUTO, @@ -61,9 +62,26 @@ describe('Dynamic Configuration Parity', () => { ]; const flagCombos = [ - { useGemini3_1: false, useCustomToolModel: false }, - { useGemini3_1: true, useCustomToolModel: false }, - { useGemini3_1: true, useCustomToolModel: true }, + { + useGemini3_1: false, + useGemini3_1FlashLite: false, + useCustomToolModel: false, + }, + { + useGemini3_1: true, + useGemini3_1FlashLite: false, + useCustomToolModel: false, + }, + { + useGemini3_1: true, + useGemini3_1FlashLite: true, + useCustomToolModel: false, + }, + { + useGemini3_1: true, + useGemini3_1FlashLite: true, + useCustomToolModel: true, + }, ]; it('resolveModel should match legacy behavior when dynamicModelConfiguration flag enabled.', () => { @@ -84,6 +102,7 @@ describe('Dynamic Configuration Parity', () => { const legacy = resolveModel( model, flags.useGemini3_1, + flags.useGemini3_1FlashLite, flags.useCustomToolModel, hasAccess, mockLegacyConfig, @@ -91,6 +110,7 @@ describe('Dynamic Configuration Parity', () => { const dynamic = resolveModel( model, flags.useGemini3_1, + flags.useGemini3_1FlashLite, flags.useCustomToolModel, hasAccess, mockDynamicConfig, @@ -129,6 +149,7 @@ describe('Dynamic Configuration Parity', () => { anchor, tier, flags.useGemini3_1, + flags.useGemini3_1FlashLite, flags.useCustomToolModel, hasAccess, mockLegacyConfig, @@ -137,6 +158,7 @@ describe('Dynamic Configuration Parity', () => { anchor, tier, flags.useGemini3_1, + flags.useGemini3_1FlashLite, flags.useCustomToolModel, hasAccess, mockDynamicConfig, @@ -369,7 +391,7 @@ describe('resolveModel', () => { }); it('should return Gemini 3.1 Pro Custom Tools when auto-gemini-3 is requested, useGemini3_1 is true, and useCustomToolModel is true', () => { - const model = resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, true); + const model = resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, false, true); expect(model).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL); }); @@ -378,6 +400,16 @@ describe('resolveModel', () => { expect(model).toBe(DEFAULT_GEMINI_MODEL); }); + it('should return the Default Flash-Lite model when flash-lite is requested', () => { + const model = resolveModel(GEMINI_MODEL_ALIAS_FLASH_LITE); + expect(model).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL); + }); + + it('should return the Preview Flash-Lite model when flash-lite is requested and useGemini3_1FlashLite is true', () => { + const model = resolveModel(GEMINI_MODEL_ALIAS_FLASH_LITE, false, true); + expect(model).toBe(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL); + }); + it('should return the requested model as-is for explicit specific models', () => { expect(resolveModel(DEFAULT_GEMINI_MODEL)).toBe(DEFAULT_GEMINI_MODEL); expect(resolveModel(DEFAULT_GEMINI_FLASH_MODEL)).toBe( @@ -397,39 +429,45 @@ describe('resolveModel', () => { describe('hasAccessToPreview logic', () => { it('should return default model when access to preview is false and preview model is requested', () => { - expect(resolveModel(PREVIEW_GEMINI_MODEL, false, false, false)).toBe( - DEFAULT_GEMINI_MODEL, - ); + expect( + resolveModel(PREVIEW_GEMINI_MODEL, false, false, false, false), + ).toBe(DEFAULT_GEMINI_MODEL); }); it('should return default flash model when access to preview is false and preview flash model is requested', () => { expect( - resolveModel(PREVIEW_GEMINI_FLASH_MODEL, false, false, false), + resolveModel(PREVIEW_GEMINI_FLASH_MODEL, false, false, false, false), ).toBe(DEFAULT_GEMINI_FLASH_MODEL); }); it('should return default flash lite model when access to preview is false and preview flash lite model is requested', () => { expect( - resolveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, false, false, false), + resolveModel( + PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, + false, + false, + false, + false, + ), ).toBe(DEFAULT_GEMINI_FLASH_LITE_MODEL); }); it('should return default model when access to preview is false and auto-gemini-3 is requested', () => { - expect(resolveModel(PREVIEW_GEMINI_MODEL_AUTO, false, false, false)).toBe( - DEFAULT_GEMINI_MODEL, - ); + expect( + resolveModel(PREVIEW_GEMINI_MODEL_AUTO, false, false, false, false), + ).toBe(DEFAULT_GEMINI_MODEL); }); it('should return default model when access to preview is false and Gemini 3.1 is requested', () => { - expect(resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, false, false)).toBe( - DEFAULT_GEMINI_MODEL, - ); + expect( + resolveModel(PREVIEW_GEMINI_MODEL_AUTO, true, false, false, false), + ).toBe(DEFAULT_GEMINI_MODEL); }); it('should still return default model when access to preview is false and auto-gemini-2.5 is requested', () => { - expect(resolveModel(DEFAULT_GEMINI_MODEL_AUTO, false, false, false)).toBe( - DEFAULT_GEMINI_MODEL, - ); + expect( + resolveModel(DEFAULT_GEMINI_MODEL_AUTO, false, false, false, false), + ).toBe(DEFAULT_GEMINI_MODEL); }); }); }); @@ -521,6 +559,7 @@ describe('resolveClassifierModel', () => { PREVIEW_GEMINI_MODEL_AUTO, GEMINI_MODEL_ALIAS_PRO, true, + false, true, ), ).toBe(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL); @@ -532,7 +571,11 @@ describe('isActiveModel', () => { expect(isActiveModel(DEFAULT_GEMINI_MODEL)).toBe(true); expect(isActiveModel(PREVIEW_GEMINI_MODEL)).toBe(true); expect(isActiveModel(DEFAULT_GEMINI_FLASH_MODEL)).toBe(true); - expect(isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL)).toBe(true); + }); + + it('should return false for Gemini 3.1 models when Gemini 3.1 is not launched', () => { + expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL)).toBe(false); + expect(isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL)).toBe(false); }); it('should return true for unknown models and aliases', () => { @@ -546,31 +589,53 @@ describe('isActiveModel', () => { it('should return true for other valid models when useGemini3_1 is true', () => { expect(isActiveModel(DEFAULT_GEMINI_MODEL, true)).toBe(true); - expect(isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, true)).toBe(true); + }); + + it('should return true for PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL only when useGemini3_1FlashLite is true', () => { + expect( + isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, false, true), + ).toBe(true); + expect(isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, true, true)).toBe( + true, + ); + expect( + isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, true, false), + ).toBe(false); }); it('should correctly filter Gemini 3.1 models based on useCustomToolModel when useGemini3_1 is true', () => { // When custom tools are preferred, standard 3.1 should be inactive - expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, true)).toBe(false); + expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, false, true)).toBe( + false, + ); expect( - isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, true), + isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, false, true), ).toBe(true); // When custom tools are NOT preferred, custom tools 3.1 should be inactive - expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, false)).toBe(true); + expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, true, false, false)).toBe( + true, + ); expect( - isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, false), + isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, true, false, false), ).toBe(false); }); - it('should return false for both Gemini 3.1 models when useGemini3_1 is false', () => { - expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, false, true)).toBe(false); - expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, false, false)).toBe(false); + it('should return false for Gemini 3.1 models when useGemini3_1 and useGemini3_1FlashLite are false', () => { + expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, false, false, true)).toBe( + false, + ); + expect(isActiveModel(PREVIEW_GEMINI_3_1_MODEL, false, false, false)).toBe( + false, + ); expect( - isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, false, true), + isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, false, false, true), ).toBe(false); expect( - isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, false, false), + isActiveModel(PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL, false, false, false), + ).toBe(false); + expect( + isActiveModel(PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, false, false), ).toBe(false); }); }); diff --git a/packages/core/src/config/models.ts b/packages/core/src/config/models.ts index f356bebbaa..b8420dd259 100644 --- a/packages/core/src/config/models.ts +++ b/packages/core/src/config/models.ts @@ -6,6 +6,7 @@ export interface ModelResolutionContext { useGemini3_1?: boolean; + useGemini3_1FlashLite?: boolean; useCustomTools?: boolean; hasAccessToPreview?: boolean; requestedModel?: string; @@ -97,6 +98,7 @@ export const DEFAULT_THINKING_MODE = 8192; export function resolveModel( requestedModel: string, useGemini3_1: boolean = false, + useGemini3_1FlashLite: boolean = false, useCustomToolModel: boolean = false, hasAccessToPreview: boolean = true, config?: ModelCapabilityContext, @@ -104,6 +106,7 @@ export function resolveModel( if (config?.getExperimentalDynamicModelConfiguration?.() === true) { const resolved = config.modelConfigService.resolveModelId(requestedModel, { useGemini3_1, + useGemini3_1FlashLite, useCustomTools: useCustomToolModel, hasAccessToPreview, }); @@ -146,7 +149,9 @@ export function resolveModel( break; } case GEMINI_MODEL_ALIAS_FLASH_LITE: { - resolved = DEFAULT_GEMINI_FLASH_LITE_MODEL; + resolved = useGemini3_1FlashLite + ? PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL + : DEFAULT_GEMINI_FLASH_LITE_MODEL; break; } default: { @@ -160,6 +165,8 @@ export function resolveModel( switch (resolved) { case PREVIEW_GEMINI_FLASH_MODEL: return DEFAULT_GEMINI_FLASH_MODEL; + case PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL: + return DEFAULT_GEMINI_FLASH_LITE_MODEL; case PREVIEW_GEMINI_MODEL: case PREVIEW_GEMINI_3_1_MODEL: case PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL: @@ -193,6 +200,7 @@ export function resolveClassifierModel( requestedModel: string, modelAlias: string, useGemini3_1: boolean = false, + useGemini3_1FlashLite: boolean = false, useCustomToolModel: boolean = false, hasAccessToPreview: boolean = true, config?: ModelCapabilityContext, @@ -203,6 +211,7 @@ export function resolveClassifierModel( requestedModel, { useGemini3_1, + useGemini3_1FlashLite, useCustomTools: useCustomToolModel, hasAccessToPreview, }, @@ -224,7 +233,12 @@ export function resolveClassifierModel( } return resolveModel(GEMINI_MODEL_ALIAS_FLASH); } - return resolveModel(requestedModel, useGemini3_1, useCustomToolModel); + return resolveModel( + requestedModel, + useGemini3_1, + useGemini3_1FlashLite, + useCustomToolModel, + ); } export function getDisplayString( @@ -249,6 +263,8 @@ export function getDisplayString( return PREVIEW_GEMINI_FLASH_MODEL; case PREVIEW_GEMINI_3_1_CUSTOM_TOOLS_MODEL: return PREVIEW_GEMINI_3_1_MODEL; + case PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL: + return PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL; default: return model; } @@ -347,7 +363,7 @@ export function isCustomModel( config?: ModelCapabilityContext, ): boolean { if (config?.getExperimentalDynamicModelConfiguration?.() === true) { - const resolved = resolveModel(model, false, false, true, config); + const resolved = resolveModel(model, false, false, false, true, config); return ( config.modelConfigService.getModelDefinition(resolved)?.tier === 'custom' || !resolved.startsWith('gemini-') @@ -420,11 +436,15 @@ export function supportsMultimodalFunctionResponse( export function isActiveModel( model: string, useGemini3_1: boolean = false, + useGemini3_1FlashLite: boolean = false, useCustomToolModel: boolean = false, ): boolean { if (!VALID_GEMINI_MODELS.has(model)) { return false; } + if (model === PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL) { + return useGemini3_1FlashLite; + } if (useGemini3_1) { if (model === PREVIEW_GEMINI_MODEL) { return false; diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 443a663219..b37d4ad91c 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -575,6 +575,7 @@ export class GeminiClient { return resolveModel( this.config.getActiveModel(), this.config.getGemini31LaunchedSync?.() ?? false, + this.config.getGemini31FlashLiteLaunchedSync?.() ?? false, false, this.config.getHasAccessToPreviewModel?.() ?? true, this.config, diff --git a/packages/core/src/core/contentGenerator.ts b/packages/core/src/core/contentGenerator.ts index c901562eb7..0a688eb1bc 100644 --- a/packages/core/src/core/contentGenerator.ts +++ b/packages/core/src/core/contentGenerator.ts @@ -180,6 +180,9 @@ export async function createContentGenerator( config.authType === AuthType.USE_GEMINI || config.authType === AuthType.USE_VERTEX_AI || ((await gcConfig.getGemini31Launched?.()) ?? false), + config.authType === AuthType.USE_GEMINI || + config.authType === AuthType.USE_VERTEX_AI || + ((await gcConfig.getGemini31FlashLiteLaunched?.()) ?? false), false, gcConfig.getHasAccessToPreviewModel?.() ?? true, gcConfig, diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 236d219228..abea19022a 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -524,12 +524,18 @@ export class GeminiChat { const apiCall = async () => { const useGemini3_1 = (await this.context.config.getGemini31Launched?.()) ?? false; + const useGemini3_1FlashLite = + (await this.context.config.getGemini31FlashLiteLaunched?.()) ?? false; + const hasAccessToPreview = + this.context.config.getHasAccessToPreviewModel?.() ?? true; + // Default to the last used model (which respects arguments/availability selection) let modelToUse = resolveModel( lastModelToUse, useGemini3_1, + useGemini3_1FlashLite, false, - this.context.config.getHasAccessToPreviewModel?.() ?? true, + hasAccessToPreview, this.context.config, ); @@ -539,8 +545,9 @@ export class GeminiChat { modelToUse = resolveModel( this.context.config.getActiveModel(), useGemini3_1, + useGemini3_1FlashLite, false, - this.context.config.getHasAccessToPreviewModel?.() ?? true, + hasAccessToPreview, this.context.config, ); } diff --git a/packages/core/src/prompts/promptProvider.ts b/packages/core/src/prompts/promptProvider.ts index 00765a2a89..d97e636993 100644 --- a/packages/core/src/prompts/promptProvider.ts +++ b/packages/core/src/prompts/promptProvider.ts @@ -62,6 +62,7 @@ export class PromptProvider { const desiredModel = resolveModel( context.config.getActiveModel(), context.config.getGemini31LaunchedSync?.() ?? false, + context.config.getGemini31FlashLiteLaunchedSync?.() ?? false, false, context.config.getHasAccessToPreviewModel?.() ?? true, context.config, @@ -247,6 +248,7 @@ export class PromptProvider { const desiredModel = resolveModel( context.config.getActiveModel(), context.config.getGemini31LaunchedSync?.() ?? false, + context.config.getGemini31FlashLiteLaunchedSync?.() ?? false, false, context.config.getHasAccessToPreviewModel?.() ?? true, context.config, diff --git a/packages/core/src/routing/strategies/classifierStrategy.test.ts b/packages/core/src/routing/strategies/classifierStrategy.test.ts index 58908a7d3b..373da6f144 100644 --- a/packages/core/src/routing/strategies/classifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/classifierStrategy.test.ts @@ -59,6 +59,7 @@ describe('ClassifierStrategy', () => { getModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL_AUTO), getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false), getGemini31Launched: vi.fn().mockResolvedValue(false), + getGemini31FlashLiteLaunched: vi.fn().mockResolvedValue(false), getUseCustomToolModel: vi.fn().mockImplementation(async () => { const launched = await mockConfig.getGemini31Launched(); const authType = mockConfig.getContentGeneratorConfig().authType; diff --git a/packages/core/src/routing/strategies/classifierStrategy.ts b/packages/core/src/routing/strategies/classifierStrategy.ts index e27b69ed0f..1dd09f4596 100644 --- a/packages/core/src/routing/strategies/classifierStrategy.ts +++ b/packages/core/src/routing/strategies/classifierStrategy.ts @@ -171,14 +171,17 @@ export class ClassifierStrategy implements RoutingStrategy { const reasoning = routerResponse.reasoning; const latencyMs = Date.now() - startTime; - const [useGemini3_1, useCustomToolModel] = await Promise.all([ - config.getGemini31Launched(), - config.getUseCustomToolModel(), - ]); + const [useGemini3_1, useGemini3_1FlashLite, useCustomToolModel] = + await Promise.all([ + config.getGemini31Launched(), + config.getGemini31FlashLiteLaunched(), + config.getUseCustomToolModel(), + ]); const selectedModel = resolveClassifierModel( model, routerResponse.model_choice, useGemini3_1, + useGemini3_1FlashLite, useCustomToolModel, config.getHasAccessToPreviewModel?.() ?? true, config, diff --git a/packages/core/src/routing/strategies/defaultStrategy.ts b/packages/core/src/routing/strategies/defaultStrategy.ts index a2c02e83b7..c43e013ae8 100644 --- a/packages/core/src/routing/strategies/defaultStrategy.ts +++ b/packages/core/src/routing/strategies/defaultStrategy.ts @@ -26,6 +26,7 @@ export class DefaultStrategy implements TerminalStrategy { const defaultModel = resolveModel( config.getModel(), config.getGemini31LaunchedSync?.() ?? false, + config.getGemini31FlashLiteLaunchedSync?.() ?? false, false, config.getHasAccessToPreviewModel?.() ?? true, config, diff --git a/packages/core/src/routing/strategies/fallbackStrategy.ts b/packages/core/src/routing/strategies/fallbackStrategy.ts index 653f712c14..c911fb859e 100644 --- a/packages/core/src/routing/strategies/fallbackStrategy.ts +++ b/packages/core/src/routing/strategies/fallbackStrategy.ts @@ -28,6 +28,7 @@ export class FallbackStrategy implements RoutingStrategy { const resolvedModel = resolveModel( requestedModel, config.getGemini31LaunchedSync?.() ?? false, + config.getGemini31FlashLiteLaunchedSync?.() ?? false, false, config.getHasAccessToPreviewModel?.() ?? true, config, diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts index d8a9c48ed1..dcfdff786b 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.test.ts @@ -59,6 +59,7 @@ describe('NumericalClassifierStrategy', () => { getResolvedClassifierThreshold: vi.fn().mockResolvedValue(90), getClassifierThreshold: vi.fn().mockResolvedValue(undefined), getGemini31Launched: vi.fn().mockResolvedValue(false), + getGemini31FlashLiteLaunched: vi.fn().mockResolvedValue(false), getUseCustomToolModel: vi.fn().mockImplementation(async () => { const launched = await mockConfig.getGemini31Launched(); const authType = mockConfig.getContentGeneratorConfig().authType; diff --git a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts index cda761e9ff..8bcfb3da67 100644 --- a/packages/core/src/routing/strategies/numericalClassifierStrategy.ts +++ b/packages/core/src/routing/strategies/numericalClassifierStrategy.ts @@ -147,14 +147,17 @@ export class NumericalClassifierStrategy implements RoutingStrategy { const { threshold, groupLabel, modelAlias } = await this.getRoutingDecision(score, config); - const [useGemini3_1, useCustomToolModel] = await Promise.all([ - config.getGemini31Launched(), - config.getUseCustomToolModel(), - ]); + const [useGemini3_1, useGemini3_1FlashLite, useCustomToolModel] = + await Promise.all([ + config.getGemini31Launched(), + config.getGemini31FlashLiteLaunched(), + config.getUseCustomToolModel(), + ]); const selectedModel = resolveClassifierModel( model, modelAlias, useGemini3_1, + useGemini3_1FlashLite, useCustomToolModel, config.getHasAccessToPreviewModel?.() ?? true, config, diff --git a/packages/core/src/routing/strategies/overrideStrategy.ts b/packages/core/src/routing/strategies/overrideStrategy.ts index e424e533be..e93c0870ef 100644 --- a/packages/core/src/routing/strategies/overrideStrategy.ts +++ b/packages/core/src/routing/strategies/overrideStrategy.ts @@ -38,6 +38,7 @@ export class OverrideStrategy implements RoutingStrategy { model: resolveModel( overrideModel, config.getGemini31LaunchedSync?.() ?? false, + config.getGemini31FlashLiteLaunchedSync?.() ?? false, false, config.getHasAccessToPreviewModel?.() ?? true, config, diff --git a/packages/core/src/services/chatCompressionService.ts b/packages/core/src/services/chatCompressionService.ts index 4640860e48..992ca67cf9 100644 --- a/packages/core/src/services/chatCompressionService.ts +++ b/packages/core/src/services/chatCompressionService.ts @@ -30,6 +30,7 @@ import { PREVIEW_GEMINI_MODEL, PREVIEW_GEMINI_FLASH_MODEL, PREVIEW_GEMINI_3_1_MODEL, + PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL, } from '../config/models.js'; import { PreCompressTrigger } from '../hooks/types.js'; @@ -105,6 +106,8 @@ export function modelStringToModelConfigAlias(model: string): string { return 'chat-compression-3-pro'; case PREVIEW_GEMINI_FLASH_MODEL: return 'chat-compression-3-flash'; + case PREVIEW_GEMINI_3_1_FLASH_LITE_MODEL: + return 'chat-compression-3.1-flash-lite'; case DEFAULT_GEMINI_MODEL: return 'chat-compression-2.5-pro'; case DEFAULT_GEMINI_FLASH_MODEL: diff --git a/packages/core/src/services/modelConfigService.ts b/packages/core/src/services/modelConfigService.ts index e88f1287d5..d92532fd3a 100644 --- a/packages/core/src/services/modelConfigService.ts +++ b/packages/core/src/services/modelConfigService.ts @@ -90,6 +90,7 @@ export interface ModelResolution { /** The actual state of the current session. */ export interface ResolutionContext { useGemini3_1?: boolean; + useGemini3_1FlashLite?: boolean; useCustomTools?: boolean; hasAccessToPreview?: boolean; requestedModel?: string; @@ -98,6 +99,7 @@ export interface ResolutionContext { /** The requirements defined in the registry. */ export interface ResolutionCondition { useGemini3_1?: boolean; + useGemini3_1FlashLite?: boolean; useCustomTools?: boolean; hasAccessToPreview?: boolean; /** Matches if the current model is in this list. */ @@ -165,6 +167,8 @@ export class ModelConfigService { switch (key) { case 'useGemini3_1': return value === context.useGemini3_1; + case 'useGemini3_1FlashLite': + return value === context.useGemini3_1FlashLite; case 'useCustomTools': return value === context.useCustomTools; case 'hasAccessToPreview': diff --git a/packages/core/src/services/test-data/resolved-aliases-retry.golden.json b/packages/core/src/services/test-data/resolved-aliases-retry.golden.json index bb6dabdd6b..52e2eb7722 100644 --- a/packages/core/src/services/test-data/resolved-aliases-retry.golden.json +++ b/packages/core/src/services/test-data/resolved-aliases-retry.golden.json @@ -237,6 +237,10 @@ "model": "gemini-3-flash-preview", "generateContentConfig": {} }, + "chat-compression-3.1-flash-lite": { + "model": "gemini-3.1-flash-lite-preview", + "generateContentConfig": {} + }, "chat-compression-2.5-pro": { "model": "gemini-2.5-pro", "generateContentConfig": {} diff --git a/packages/core/src/services/test-data/resolved-aliases.golden.json b/packages/core/src/services/test-data/resolved-aliases.golden.json index bb6dabdd6b..52e2eb7722 100644 --- a/packages/core/src/services/test-data/resolved-aliases.golden.json +++ b/packages/core/src/services/test-data/resolved-aliases.golden.json @@ -237,6 +237,10 @@ "model": "gemini-3-flash-preview", "generateContentConfig": {} }, + "chat-compression-3.1-flash-lite": { + "model": "gemini-3.1-flash-lite-preview", + "generateContentConfig": {} + }, "chat-compression-2.5-pro": { "model": "gemini-2.5-pro", "generateContentConfig": {} diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index b84e660262..28194b587c 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -636,7 +636,7 @@ "modelConfigs": { "title": "Model Configs", "description": "Model configurations.", - "markdownDescription": "Model configurations.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{\n \"aliases\": {\n \"base\": {\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 0,\n \"topP\": 1\n }\n }\n },\n \"chat-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"includeThoughts\": true\n },\n \"temperature\": 1,\n \"topP\": 0.95,\n \"topK\": 64\n }\n }\n },\n \"chat-base-2.5\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 8192\n }\n }\n }\n },\n \"chat-base-3\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingLevel\": \"HIGH\"\n }\n }\n }\n },\n \"gemini-3-pro-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"gemini-3-flash-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"gemini-2.5-pro\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"gemini-2.5-flash\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"gemini-2.5-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-3-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"classifier\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 1024,\n \"thinkingConfig\": {\n \"thinkingBudget\": 512\n }\n }\n }\n },\n \"prompt-completion\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.3,\n \"maxOutputTokens\": 16000,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"fast-ack-helper\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.2,\n \"maxOutputTokens\": 120,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"edit-corrector\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"summarizer-default\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"summarizer-shell\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"web-search\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"googleSearch\": {}\n }\n ]\n }\n }\n },\n \"web-fetch\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"urlContext\": {}\n }\n ]\n }\n }\n },\n \"web-fetch-fallback\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection-double-check\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"llm-edit-fixer\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"next-speaker-checker\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"chat-compression-3-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"chat-compression-3-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"chat-compression-2.5-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"chat-compression-2.5-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"chat-compression-2.5-flash-lite\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"chat-compression-default\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n }\n },\n \"overrides\": [\n {\n \"match\": {\n \"model\": \"chat-base\",\n \"isRetry\": true\n },\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 1\n }\n }\n }\n ],\n \"modelDefinitions\": {\n \"gemini-3.1-flash-lite-preview\": {\n \"tier\": \"flash-lite\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3.1-pro-preview\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3.1-pro-preview-customtools\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3-pro-preview\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3-flash-preview\": {\n \"tier\": \"flash\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-2.5-pro\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-2.5\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"gemini-2.5-flash\": {\n \"tier\": \"flash\",\n \"family\": \"gemini-2.5\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"tier\": \"flash-lite\",\n \"family\": \"gemini-2.5\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"auto\": {\n \"tier\": \"auto\",\n \"isPreview\": true,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": false\n }\n },\n \"pro\": {\n \"tier\": \"pro\",\n \"isPreview\": false,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": false\n }\n },\n \"flash\": {\n \"tier\": \"flash\",\n \"isPreview\": false,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"flash-lite\": {\n \"tier\": \"flash-lite\",\n \"isPreview\": false,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"auto-gemini-3\": {\n \"displayName\": \"Auto (Gemini 3)\",\n \"tier\": \"auto\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"dialogDescription\": \"Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash\",\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": false\n }\n },\n \"auto-gemini-2.5\": {\n \"displayName\": \"Auto (Gemini 2.5)\",\n \"tier\": \"auto\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"dialogDescription\": \"Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash\",\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n }\n },\n \"modelIdResolutions\": {\n \"gemini-3.1-pro-preview\": {\n \"default\": \"gemini-3.1-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n }\n ]\n },\n \"gemini-3.1-pro-preview-customtools\": {\n \"default\": \"gemini-3.1-pro-preview-customtools\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n }\n ]\n },\n \"gemini-3-flash-preview\": {\n \"default\": \"gemini-3-flash-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-flash\"\n }\n ]\n },\n \"gemini-3-pro-preview\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto-gemini-3\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"pro\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto-gemini-2.5\": {\n \"default\": \"gemini-2.5-pro\"\n },\n \"flash\": {\n \"default\": \"gemini-3-flash-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-flash\"\n }\n ]\n },\n \"flash-lite\": {\n \"default\": \"gemini-2.5-flash-lite\"\n }\n },\n \"classifierIdResolutions\": {\n \"flash\": {\n \"default\": \"gemini-3-flash-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"requestedModels\": [\n \"auto-gemini-2.5\",\n \"gemini-2.5-pro\"\n ]\n },\n \"target\": \"gemini-2.5-flash\"\n },\n {\n \"condition\": {\n \"requestedModels\": [\n \"auto-gemini-3\",\n \"gemini-3-pro-preview\"\n ]\n },\n \"target\": \"gemini-3-flash-preview\"\n }\n ]\n },\n \"pro\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"requestedModels\": [\n \"auto-gemini-2.5\",\n \"gemini-2.5-pro\"\n ]\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n }\n },\n \"modelChains\": {\n \"preview\": [\n {\n \"model\": \"gemini-3-pro-preview\",\n \"actions\": {\n \"terminal\": \"prompt\",\n \"transient\": \"prompt\",\n \"not_found\": \"prompt\",\n \"unknown\": \"prompt\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n },\n {\n \"model\": \"gemini-3-flash-preview\",\n \"isLastResort\": true,\n \"actions\": {\n \"terminal\": \"prompt\",\n \"transient\": \"prompt\",\n \"not_found\": \"prompt\",\n \"unknown\": \"prompt\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n }\n ],\n \"default\": [\n {\n \"model\": \"gemini-2.5-pro\",\n \"actions\": {\n \"terminal\": \"prompt\",\n \"transient\": \"prompt\",\n \"not_found\": \"prompt\",\n \"unknown\": \"prompt\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n },\n {\n \"model\": \"gemini-2.5-flash\",\n \"isLastResort\": true,\n \"actions\": {\n \"terminal\": \"prompt\",\n \"transient\": \"prompt\",\n \"not_found\": \"prompt\",\n \"unknown\": \"prompt\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n }\n ],\n \"lite\": [\n {\n \"model\": \"gemini-2.5-flash-lite\",\n \"actions\": {\n \"terminal\": \"silent\",\n \"transient\": \"silent\",\n \"not_found\": \"silent\",\n \"unknown\": \"silent\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n },\n {\n \"model\": \"gemini-2.5-flash\",\n \"actions\": {\n \"terminal\": \"silent\",\n \"transient\": \"silent\",\n \"not_found\": \"silent\",\n \"unknown\": \"silent\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n },\n {\n \"model\": \"gemini-2.5-pro\",\n \"isLastResort\": true,\n \"actions\": {\n \"terminal\": \"silent\",\n \"transient\": \"silent\",\n \"not_found\": \"silent\",\n \"unknown\": \"silent\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n }\n ]\n }\n}`", + "markdownDescription": "Model configurations.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{\n \"aliases\": {\n \"base\": {\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 0,\n \"topP\": 1\n }\n }\n },\n \"chat-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"includeThoughts\": true\n },\n \"temperature\": 1,\n \"topP\": 0.95,\n \"topK\": 64\n }\n }\n },\n \"chat-base-2.5\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 8192\n }\n }\n }\n },\n \"chat-base-3\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingLevel\": \"HIGH\"\n }\n }\n }\n },\n \"gemini-3-pro-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"gemini-3-flash-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"gemini-2.5-pro\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"gemini-2.5-flash\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"gemini-2.5-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-3-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"classifier\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 1024,\n \"thinkingConfig\": {\n \"thinkingBudget\": 512\n }\n }\n }\n },\n \"prompt-completion\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.3,\n \"maxOutputTokens\": 16000,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"fast-ack-helper\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.2,\n \"maxOutputTokens\": 120,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"edit-corrector\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"summarizer-default\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"summarizer-shell\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"web-search\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"googleSearch\": {}\n }\n ]\n }\n }\n },\n \"web-fetch\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"urlContext\": {}\n }\n ]\n }\n }\n },\n \"web-fetch-fallback\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection-double-check\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"llm-edit-fixer\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"next-speaker-checker\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"chat-compression-3-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"chat-compression-3-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"chat-compression-3.1-flash-lite\": {\n \"modelConfig\": {\n \"model\": \"gemini-3.1-flash-lite-preview\"\n }\n },\n \"chat-compression-2.5-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"chat-compression-2.5-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"chat-compression-2.5-flash-lite\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"chat-compression-default\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n }\n },\n \"overrides\": [\n {\n \"match\": {\n \"model\": \"chat-base\",\n \"isRetry\": true\n },\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 1\n }\n }\n }\n ],\n \"modelDefinitions\": {\n \"gemini-3.1-flash-lite-preview\": {\n \"tier\": \"flash-lite\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3.1-pro-preview\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3.1-pro-preview-customtools\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3-pro-preview\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-3-flash-preview\": {\n \"tier\": \"flash\",\n \"family\": \"gemini-3\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": true\n }\n },\n \"gemini-2.5-pro\": {\n \"tier\": \"pro\",\n \"family\": \"gemini-2.5\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"gemini-2.5-flash\": {\n \"tier\": \"flash\",\n \"family\": \"gemini-2.5\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"tier\": \"flash-lite\",\n \"family\": \"gemini-2.5\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"auto\": {\n \"tier\": \"auto\",\n \"isPreview\": true,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": false\n }\n },\n \"pro\": {\n \"tier\": \"pro\",\n \"isPreview\": false,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": false\n }\n },\n \"flash\": {\n \"tier\": \"flash\",\n \"isPreview\": false,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"flash-lite\": {\n \"tier\": \"flash-lite\",\n \"isPreview\": false,\n \"isVisible\": false,\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n },\n \"auto-gemini-3\": {\n \"displayName\": \"Auto (Gemini 3)\",\n \"tier\": \"auto\",\n \"isPreview\": true,\n \"isVisible\": true,\n \"dialogDescription\": \"Let Gemini CLI decide the best model for the task: gemini-3-pro, gemini-3-flash\",\n \"features\": {\n \"thinking\": true,\n \"multimodalToolUse\": false\n }\n },\n \"auto-gemini-2.5\": {\n \"displayName\": \"Auto (Gemini 2.5)\",\n \"tier\": \"auto\",\n \"isPreview\": false,\n \"isVisible\": true,\n \"dialogDescription\": \"Let Gemini CLI decide the best model for the task: gemini-2.5-pro, gemini-2.5-flash\",\n \"features\": {\n \"thinking\": false,\n \"multimodalToolUse\": false\n }\n }\n },\n \"modelIdResolutions\": {\n \"gemini-3.1-pro-preview\": {\n \"default\": \"gemini-3.1-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n }\n ]\n },\n \"gemini-3.1-pro-preview-customtools\": {\n \"default\": \"gemini-3.1-pro-preview-customtools\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n }\n ]\n },\n \"gemini-3-flash-preview\": {\n \"default\": \"gemini-3-flash-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-flash\"\n }\n ]\n },\n \"gemini-3-pro-preview\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto-gemini-3\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"pro\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto-gemini-2.5\": {\n \"default\": \"gemini-2.5-pro\"\n },\n \"gemini-3.1-flash-lite-preview\": {\n \"default\": \"gemini-3.1-flash-lite-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"useGemini3_1FlashLite\": false\n },\n \"target\": \"gemini-2.5-flash-lite\"\n }\n ]\n },\n \"flash\": {\n \"default\": \"gemini-3-flash-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-flash\"\n }\n ]\n },\n \"flash-lite\": {\n \"default\": \"gemini-2.5-flash-lite\",\n \"contexts\": [\n {\n \"condition\": {\n \"useGemini3_1FlashLite\": true\n },\n \"target\": \"gemini-3.1-flash-lite-preview\"\n }\n ]\n }\n },\n \"classifierIdResolutions\": {\n \"flash\": {\n \"default\": \"gemini-3-flash-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"requestedModels\": [\n \"auto-gemini-2.5\",\n \"gemini-2.5-pro\"\n ]\n },\n \"target\": \"gemini-2.5-flash\"\n },\n {\n \"condition\": {\n \"requestedModels\": [\n \"auto-gemini-3\",\n \"gemini-3-pro-preview\"\n ]\n },\n \"target\": \"gemini-3-flash-preview\"\n }\n ]\n },\n \"pro\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"requestedModels\": [\n \"auto-gemini-2.5\",\n \"gemini-2.5-pro\"\n ]\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n }\n },\n \"modelChains\": {\n \"preview\": [\n {\n \"model\": \"gemini-3-pro-preview\",\n \"actions\": {\n \"terminal\": \"prompt\",\n \"transient\": \"prompt\",\n \"not_found\": \"prompt\",\n \"unknown\": \"prompt\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n },\n {\n \"model\": \"gemini-3-flash-preview\",\n \"isLastResort\": true,\n \"actions\": {\n \"terminal\": \"prompt\",\n \"transient\": \"prompt\",\n \"not_found\": \"prompt\",\n \"unknown\": \"prompt\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n }\n ],\n \"default\": [\n {\n \"model\": \"gemini-2.5-pro\",\n \"actions\": {\n \"terminal\": \"prompt\",\n \"transient\": \"prompt\",\n \"not_found\": \"prompt\",\n \"unknown\": \"prompt\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n },\n {\n \"model\": \"gemini-2.5-flash\",\n \"isLastResort\": true,\n \"actions\": {\n \"terminal\": \"prompt\",\n \"transient\": \"prompt\",\n \"not_found\": \"prompt\",\n \"unknown\": \"prompt\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n }\n ],\n \"lite\": [\n {\n \"model\": \"gemini-2.5-flash-lite\",\n \"actions\": {\n \"terminal\": \"silent\",\n \"transient\": \"silent\",\n \"not_found\": \"silent\",\n \"unknown\": \"silent\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n },\n {\n \"model\": \"gemini-2.5-flash\",\n \"actions\": {\n \"terminal\": \"silent\",\n \"transient\": \"silent\",\n \"not_found\": \"silent\",\n \"unknown\": \"silent\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n },\n {\n \"model\": \"gemini-2.5-pro\",\n \"isLastResort\": true,\n \"actions\": {\n \"terminal\": \"silent\",\n \"transient\": \"silent\",\n \"not_found\": \"silent\",\n \"unknown\": \"silent\"\n },\n \"stateTransitions\": {\n \"terminal\": \"terminal\",\n \"transient\": \"terminal\",\n \"not_found\": \"terminal\",\n \"unknown\": \"terminal\"\n }\n }\n ]\n }\n}`", "default": { "aliases": { "base": { @@ -845,6 +845,11 @@ "model": "gemini-3-flash-preview" } }, + "chat-compression-3.1-flash-lite": { + "modelConfig": { + "model": "gemini-3.1-flash-lite-preview" + } + }, "chat-compression-2.5-pro": { "modelConfig": { "model": "gemini-2.5-pro" @@ -1158,6 +1163,17 @@ "auto-gemini-2.5": { "default": "gemini-2.5-pro" }, + "gemini-3.1-flash-lite-preview": { + "default": "gemini-3.1-flash-lite-preview", + "contexts": [ + { + "condition": { + "useGemini3_1FlashLite": false + }, + "target": "gemini-2.5-flash-lite" + } + ] + }, "flash": { "default": "gemini-3-flash-preview", "contexts": [ @@ -1170,7 +1186,15 @@ ] }, "flash-lite": { - "default": "gemini-2.5-flash-lite" + "default": "gemini-2.5-flash-lite", + "contexts": [ + { + "condition": { + "useGemini3_1FlashLite": true + }, + "target": "gemini-3.1-flash-lite-preview" + } + ] } }, "classifierIdResolutions": { @@ -1338,7 +1362,7 @@ "aliases": { "title": "Model Config Aliases", "description": "Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.", - "markdownDescription": "Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{\n \"base\": {\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 0,\n \"topP\": 1\n }\n }\n },\n \"chat-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"includeThoughts\": true\n },\n \"temperature\": 1,\n \"topP\": 0.95,\n \"topK\": 64\n }\n }\n },\n \"chat-base-2.5\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 8192\n }\n }\n }\n },\n \"chat-base-3\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingLevel\": \"HIGH\"\n }\n }\n }\n },\n \"gemini-3-pro-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"gemini-3-flash-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"gemini-2.5-pro\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"gemini-2.5-flash\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"gemini-2.5-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-3-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"classifier\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 1024,\n \"thinkingConfig\": {\n \"thinkingBudget\": 512\n }\n }\n }\n },\n \"prompt-completion\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.3,\n \"maxOutputTokens\": 16000,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"fast-ack-helper\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.2,\n \"maxOutputTokens\": 120,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"edit-corrector\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"summarizer-default\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"summarizer-shell\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"web-search\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"googleSearch\": {}\n }\n ]\n }\n }\n },\n \"web-fetch\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"urlContext\": {}\n }\n ]\n }\n }\n },\n \"web-fetch-fallback\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection-double-check\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"llm-edit-fixer\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"next-speaker-checker\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"chat-compression-3-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"chat-compression-3-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"chat-compression-2.5-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"chat-compression-2.5-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"chat-compression-2.5-flash-lite\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"chat-compression-default\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n }\n}`", + "markdownDescription": "Named presets for model configs. Can be used in place of a model name and can inherit from other aliases using an `extends` property.\n\n- Category: `Model`\n- Requires restart: `no`\n- Default: `{\n \"base\": {\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"temperature\": 0,\n \"topP\": 1\n }\n }\n },\n \"chat-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"includeThoughts\": true\n },\n \"temperature\": 1,\n \"topP\": 0.95,\n \"topK\": 64\n }\n }\n },\n \"chat-base-2.5\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 8192\n }\n }\n }\n },\n \"chat-base-3\": {\n \"extends\": \"chat-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingLevel\": \"HIGH\"\n }\n }\n }\n },\n \"gemini-3-pro-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"gemini-3-flash-preview\": {\n \"extends\": \"chat-base-3\",\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"gemini-2.5-pro\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"gemini-2.5-flash\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-2.5-flash-lite\": {\n \"extends\": \"chat-base-2.5\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"gemini-2.5-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"gemini-3-flash-base\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"classifier\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 1024,\n \"thinkingConfig\": {\n \"thinkingBudget\": 512\n }\n }\n }\n },\n \"prompt-completion\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.3,\n \"maxOutputTokens\": 16000,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"fast-ack-helper\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"temperature\": 0.2,\n \"maxOutputTokens\": 120,\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"edit-corrector\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"thinkingConfig\": {\n \"thinkingBudget\": 0\n }\n }\n }\n },\n \"summarizer-default\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"summarizer-shell\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\",\n \"generateContentConfig\": {\n \"maxOutputTokens\": 2000\n }\n }\n },\n \"web-search\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"googleSearch\": {}\n }\n ]\n }\n }\n },\n \"web-fetch\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {\n \"generateContentConfig\": {\n \"tools\": [\n {\n \"urlContext\": {}\n }\n ]\n }\n }\n },\n \"web-fetch-fallback\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"loop-detection-double-check\": {\n \"extends\": \"base\",\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"llm-edit-fixer\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"next-speaker-checker\": {\n \"extends\": \"gemini-3-flash-base\",\n \"modelConfig\": {}\n },\n \"chat-compression-3-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n },\n \"chat-compression-3-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-flash-preview\"\n }\n },\n \"chat-compression-3.1-flash-lite\": {\n \"modelConfig\": {\n \"model\": \"gemini-3.1-flash-lite-preview\"\n }\n },\n \"chat-compression-2.5-pro\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-pro\"\n }\n },\n \"chat-compression-2.5-flash\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash\"\n }\n },\n \"chat-compression-2.5-flash-lite\": {\n \"modelConfig\": {\n \"model\": \"gemini-2.5-flash-lite\"\n }\n },\n \"chat-compression-default\": {\n \"modelConfig\": {\n \"model\": \"gemini-3-pro-preview\"\n }\n }\n}`", "default": { "base": { "modelConfig": { @@ -1546,6 +1570,11 @@ "model": "gemini-3-flash-preview" } }, + "chat-compression-3.1-flash-lite": { + "modelConfig": { + "model": "gemini-3.1-flash-lite-preview" + } + }, "chat-compression-2.5-pro": { "modelConfig": { "model": "gemini-2.5-pro" @@ -1746,7 +1775,7 @@ "modelIdResolutions": { "title": "Model ID Resolutions", "description": "Rules for resolving requested model names to concrete model IDs based on context.", - "markdownDescription": "Rules for resolving requested model names to concrete model IDs based on context.\n\n- Category: `Model`\n- Requires restart: `yes`\n- Default: `{\n \"gemini-3.1-pro-preview\": {\n \"default\": \"gemini-3.1-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n }\n ]\n },\n \"gemini-3.1-pro-preview-customtools\": {\n \"default\": \"gemini-3.1-pro-preview-customtools\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n }\n ]\n },\n \"gemini-3-flash-preview\": {\n \"default\": \"gemini-3-flash-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-flash\"\n }\n ]\n },\n \"gemini-3-pro-preview\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto-gemini-3\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"pro\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto-gemini-2.5\": {\n \"default\": \"gemini-2.5-pro\"\n },\n \"flash\": {\n \"default\": \"gemini-3-flash-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-flash\"\n }\n ]\n },\n \"flash-lite\": {\n \"default\": \"gemini-2.5-flash-lite\"\n }\n}`", + "markdownDescription": "Rules for resolving requested model names to concrete model IDs based on context.\n\n- Category: `Model`\n- Requires restart: `yes`\n- Default: `{\n \"gemini-3.1-pro-preview\": {\n \"default\": \"gemini-3.1-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n }\n ]\n },\n \"gemini-3.1-pro-preview-customtools\": {\n \"default\": \"gemini-3.1-pro-preview-customtools\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n }\n ]\n },\n \"gemini-3-flash-preview\": {\n \"default\": \"gemini-3-flash-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-flash\"\n }\n ]\n },\n \"gemini-3-pro-preview\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto-gemini-3\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"pro\": {\n \"default\": \"gemini-3-pro-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-pro\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true,\n \"useCustomTools\": true\n },\n \"target\": \"gemini-3.1-pro-preview-customtools\"\n },\n {\n \"condition\": {\n \"useGemini3_1\": true\n },\n \"target\": \"gemini-3.1-pro-preview\"\n }\n ]\n },\n \"auto-gemini-2.5\": {\n \"default\": \"gemini-2.5-pro\"\n },\n \"gemini-3.1-flash-lite-preview\": {\n \"default\": \"gemini-3.1-flash-lite-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"useGemini3_1FlashLite\": false\n },\n \"target\": \"gemini-2.5-flash-lite\"\n }\n ]\n },\n \"flash\": {\n \"default\": \"gemini-3-flash-preview\",\n \"contexts\": [\n {\n \"condition\": {\n \"hasAccessToPreview\": false\n },\n \"target\": \"gemini-2.5-flash\"\n }\n ]\n },\n \"flash-lite\": {\n \"default\": \"gemini-2.5-flash-lite\",\n \"contexts\": [\n {\n \"condition\": {\n \"useGemini3_1FlashLite\": true\n },\n \"target\": \"gemini-3.1-flash-lite-preview\"\n }\n ]\n }\n}`", "default": { "gemini-3.1-pro-preview": { "default": "gemini-3.1-pro-preview", @@ -1886,6 +1915,17 @@ "auto-gemini-2.5": { "default": "gemini-2.5-pro" }, + "gemini-3.1-flash-lite-preview": { + "default": "gemini-3.1-flash-lite-preview", + "contexts": [ + { + "condition": { + "useGemini3_1FlashLite": false + }, + "target": "gemini-2.5-flash-lite" + } + ] + }, "flash": { "default": "gemini-3-flash-preview", "contexts": [ @@ -1898,7 +1938,15 @@ ] }, "flash-lite": { - "default": "gemini-2.5-flash-lite" + "default": "gemini-2.5-flash-lite", + "contexts": [ + { + "condition": { + "useGemini3_1FlashLite": true + }, + "target": "gemini-3.1-flash-lite-preview" + } + ] } }, "type": "object", @@ -3704,6 +3752,9 @@ "useGemini3_1": { "type": "boolean" }, + "useGemini3_1FlashLite": { + "type": "boolean" + }, "useCustomTools": { "type": "boolean" }, From 124d5cfb9e4f58f207f1e4a8d8a6bc1c5ade7323 Mon Sep 17 00:00:00 2001 From: gemini-cli-robot Date: Wed, 25 Mar 2026 14:32:39 -0700 Subject: [PATCH 22/49] Changelog for v0.36.0-preview.3 (#23827) Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com> --- docs/changelogs/preview.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/changelogs/preview.md b/docs/changelogs/preview.md index 13887112d9..5ccd82a279 100644 --- a/docs/changelogs/preview.md +++ b/docs/changelogs/preview.md @@ -1,6 +1,6 @@ -# Preview release: v0.36.0-preview.0 +# Preview release: v0.36.0-preview.3 -Released: March 24, 2026 +Released: March 25, 2026 Our preview release includes the latest, new, and experimental features. This release may not be as stable as our [latest weekly release](latest.md). @@ -31,6 +31,10 @@ npm install -g @google/gemini-cli@preview ## What's Changed +- fix(patch): cherry-pick 055ff92 to release/v0.36.0-preview.0-pr-23672 to patch + version v0.36.0-preview.0 and create version 0.36.0-preview.1 by + @gemini-cli-robot in + [#23723](https://github.com/google-gemini/gemini-cli/pull/23723) - Changelog for v0.33.2 by @gemini-cli-robot in [#22730](https://github.com/google-gemini/gemini-cli/pull/22730) - feat(core): multi-registry architecture and tool filtering for subagents by @@ -375,4 +379,4 @@ npm install -g @google/gemini-cli@preview [#23666](https://github.com/google-gemini/gemini-cli/pull/23666) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.0 +https://github.com/google-gemini/gemini-cli/compare/v0.35.0-preview.5...v0.36.0-preview.3 From 7b710a2790d1b905f9a9f5ebd4dd972bde0eb055 Mon Sep 17 00:00:00 2001 From: Alisa <62909685+alisa-alisa@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:43:12 -0700 Subject: [PATCH 23/49] new linting check: github-actions-pinning (#23808) --- .github/actions/push-sandbox/action.yml | 8 +- .github/actions/verify-release/action.yml | 2 +- .github/workflows/ci.yml | 7 +- .../gemini-scheduled-stale-issue-closer.yml | 4 +- .../gemini-scheduled-stale-pr-closer.yml | 4 +- .../workflows/label-backlog-child-issues.yml | 8 +- .github/workflows/label-workstream-rollup.yml | 2 +- .../pr-contribution-guidelines-notifier.yml | 2 +- .github/workflows/release-change-tags.yml | 2 +- .github/workflows/release-notes.yml | 6 +- .github/workflows/test-build-binary.yml | 8 +- .../workflows/unassign-inactive-assignees.yml | 4 +- scripts/lint.js | 80 +++++++++++++++++++ 13 files changed, 110 insertions(+), 27 deletions(-) diff --git a/.github/actions/push-sandbox/action.yml b/.github/actions/push-sandbox/action.yml index bab85af453..dd2d96c4a1 100644 --- a/.github/actions/push-sandbox/action.yml +++ b/.github/actions/push-sandbox/action.yml @@ -34,7 +34,7 @@ runs: JSON_INPUTS: '${{ toJSON(inputs) }}' run: 'echo "$JSON_INPUTS"' - name: 'Checkout' - uses: 'actions/checkout@v4' + uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4 with: ref: '${{ inputs.github-sha }}' fetch-depth: 0 @@ -45,11 +45,11 @@ runs: shell: 'bash' run: 'npm run build' - name: 'Set up QEMU' - uses: 'docker/setup-qemu-action@v3' + uses: 'docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130' # ratchet:docker/setup-qemu-action@v3 - name: 'Set up Docker Buildx' - uses: 'docker/setup-buildx-action@v3' + uses: 'docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f' # ratchet:docker/setup-buildx-action@v3 - name: 'Log in to GitHub Container Registry' - uses: 'docker/login-action@v3' + uses: 'docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9' # ratchet:docker/login-action@v3 with: registry: 'docker.io' username: '${{ inputs.dockerhub-username }}' diff --git a/.github/actions/verify-release/action.yml b/.github/actions/verify-release/action.yml index 261715c1b9..4e0c6c6f72 100644 --- a/.github/actions/verify-release/action.yml +++ b/.github/actions/verify-release/action.yml @@ -36,7 +36,7 @@ runs: run: 'echo "$JSON_INPUTS"' - name: 'setup node' - uses: 'actions/setup-node@v4' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 with: node-version: '20' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e1f329d5a..d40b49bb69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,7 +67,7 @@ jobs: cache: 'npm' - name: 'Cache Linters' - uses: 'actions/cache@v4' + uses: 'actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830' # ratchet:actions/cache@v4 with: path: '${{ env.GEMINI_LINT_TEMP_DIR }}' key: "${{ runner.os }}-${{ runner.arch }}-linters-${{ hashFiles('scripts/lint.js') }}" @@ -76,7 +76,7 @@ jobs: run: 'npm ci' - name: 'Cache ESLint' - uses: 'actions/cache@v4' + uses: 'actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830' # ratchet:actions/cache@v4 with: path: '.eslintcache' key: "${{ runner.os }}-eslint-${{ hashFiles('package-lock.json', 'eslint.config.js') }}" @@ -114,6 +114,9 @@ jobs: - name: 'Run sensitive keyword linter' run: 'node scripts/lint.js --sensitive-keywords' + - name: 'Run GitHub Actions pinning linter' + run: 'node scripts/lint.js --check-github-actions-pinning' + link_checker: name: 'Link Checker' runs-on: 'ubuntu-latest' diff --git a/.github/workflows/gemini-scheduled-stale-issue-closer.yml b/.github/workflows/gemini-scheduled-stale-issue-closer.yml index 2b7b163d88..cfbecd6490 100644 --- a/.github/workflows/gemini-scheduled-stale-issue-closer.yml +++ b/.github/workflows/gemini-scheduled-stale-issue-closer.yml @@ -28,14 +28,14 @@ jobs: steps: - name: 'Generate GitHub App Token' id: 'generate_token' - uses: 'actions/create-github-app-token@v2' + uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2 with: app-id: '${{ secrets.APP_ID }}' private-key: '${{ secrets.PRIVATE_KEY }}' permission-issues: 'write' - name: 'Process Stale Issues' - uses: 'actions/github-script@v7' + uses: 'actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b' # ratchet:actions/github-script@v7 env: DRY_RUN: '${{ inputs.dry_run }}' with: diff --git a/.github/workflows/gemini-scheduled-stale-pr-closer.yml b/.github/workflows/gemini-scheduled-stale-pr-closer.yml index cc33848941..7a8e3c1fd5 100644 --- a/.github/workflows/gemini-scheduled-stale-pr-closer.yml +++ b/.github/workflows/gemini-scheduled-stale-pr-closer.yml @@ -27,13 +27,13 @@ jobs: APP_ID: '${{ secrets.APP_ID }}' if: |- ${{ env.APP_ID != '' }} - uses: 'actions/create-github-app-token@v2' + uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2 with: app-id: '${{ secrets.APP_ID }}' private-key: '${{ secrets.PRIVATE_KEY }}' - name: 'Process Stale PRs' - uses: 'actions/github-script@v7' + uses: 'actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b' # ratchet:actions/github-script@v7 env: DRY_RUN: '${{ inputs.dry_run }}' with: diff --git a/.github/workflows/label-backlog-child-issues.yml b/.github/workflows/label-backlog-child-issues.yml index a819bf4e71..697e605d51 100644 --- a/.github/workflows/label-backlog-child-issues.yml +++ b/.github/workflows/label-backlog-child-issues.yml @@ -18,10 +18,10 @@ jobs: runs-on: 'ubuntu-latest' steps: - name: 'Checkout' - uses: 'actions/checkout@v4' + uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4 - name: 'Setup Node.js' - uses: 'actions/setup-node@v4' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 with: node-version: '20' cache: 'npm' @@ -40,10 +40,10 @@ jobs: runs-on: 'ubuntu-latest' steps: - name: 'Checkout' - uses: 'actions/checkout@v4' + uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4 - name: 'Setup Node.js' - uses: 'actions/setup-node@v4' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 with: node-version: '20' cache: 'npm' diff --git a/.github/workflows/label-workstream-rollup.yml b/.github/workflows/label-workstream-rollup.yml index 97d699d09b..9a44a9c25d 100644 --- a/.github/workflows/label-workstream-rollup.yml +++ b/.github/workflows/label-workstream-rollup.yml @@ -15,7 +15,7 @@ jobs: issues: 'write' steps: - name: 'Check for Parent Workstream and Apply Label' - uses: 'actions/github-script@v7' + uses: 'actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b' # ratchet:actions/github-script@v7 with: script: | const labelToAdd = 'workstream-rollup'; diff --git a/.github/workflows/pr-contribution-guidelines-notifier.yml b/.github/workflows/pr-contribution-guidelines-notifier.yml index 5ee1b37f57..bd08aac0ce 100644 --- a/.github/workflows/pr-contribution-guidelines-notifier.yml +++ b/.github/workflows/pr-contribution-guidelines-notifier.yml @@ -19,7 +19,7 @@ jobs: APP_ID: '${{ secrets.APP_ID }}' if: |- ${{ env.APP_ID != '' }} - uses: 'actions/create-github-app-token@v2' + uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2 with: app-id: '${{ secrets.APP_ID }}' private-key: '${{ secrets.PRIVATE_KEY }}' diff --git a/.github/workflows/release-change-tags.yml b/.github/workflows/release-change-tags.yml index c7c3f3f2d2..3a7c5648f8 100644 --- a/.github/workflows/release-change-tags.yml +++ b/.github/workflows/release-change-tags.yml @@ -40,7 +40,7 @@ jobs: issues: 'write' steps: - name: 'Checkout repository' - uses: 'actions/checkout@v4' + uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4 with: ref: '${{ github.ref }}' fetch-depth: 0 diff --git a/.github/workflows/release-notes.yml b/.github/workflows/release-notes.yml index 13bb2c2ca8..a5a2f90db8 100644 --- a/.github/workflows/release-notes.yml +++ b/.github/workflows/release-notes.yml @@ -29,14 +29,14 @@ jobs: pull-requests: 'write' steps: - name: 'Checkout repository' - uses: 'actions/checkout@v4' + uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4 with: # The user-level skills need to be available to the workflow fetch-depth: 0 ref: 'main' - name: 'Set up Node.js' - uses: 'actions/setup-node@v4' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 with: node-version: '20' @@ -86,7 +86,7 @@ jobs: - name: 'Create Pull Request' if: "steps.validate_version.outputs.CONTINUE == 'true'" - uses: 'peter-evans/create-pull-request@v6' + uses: 'peter-evans/create-pull-request@c5a7806660adbe173f04e3e038b0ccdcd758773c' # ratchet:peter-evans/create-pull-request@v6 with: token: '${{ secrets.GEMINI_CLI_ROBOT_GITHUB_PAT }}' commit-message: 'docs(changelog): update for ${{ steps.release_info.outputs.VERSION }}' diff --git a/.github/workflows/test-build-binary.yml b/.github/workflows/test-build-binary.yml index f11181a9f0..d0069b8b15 100644 --- a/.github/workflows/test-build-binary.yml +++ b/.github/workflows/test-build-binary.yml @@ -33,7 +33,7 @@ jobs: steps: - name: 'Checkout' - uses: 'actions/checkout@v4' + uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4 - name: 'Optimize Windows Performance' if: "matrix.os == 'windows-latest'" @@ -46,7 +46,7 @@ jobs: shell: 'powershell' - name: 'Set up Node.js' - uses: 'actions/setup-node@v4' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 with: node-version-file: '.nvmrc' architecture: '${{ matrix.arch }}' @@ -63,7 +63,7 @@ jobs: - name: 'Setup Windows SDK (Windows)' if: "matrix.os == 'windows-latest'" - uses: 'microsoft/setup-msbuild@v2' + uses: 'microsoft/setup-msbuild@6fb02220983dee41ce7ae257b6f4d8f9bf5ed4ce' # ratchet:microsoft/setup-msbuild@v2 - name: 'Add Signtool to Path (Windows)' if: "matrix.os == 'windows-latest'" @@ -153,7 +153,7 @@ jobs: npm run test:integration:sandbox:none -- --testTimeout=600000 - name: 'Upload Artifact' - uses: 'actions/upload-artifact@v4' + uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 with: name: 'gemini-cli-${{ matrix.platform_name }}' path: 'dist/${{ matrix.platform_name }}/' diff --git a/.github/workflows/unassign-inactive-assignees.yml b/.github/workflows/unassign-inactive-assignees.yml index dd09f0feaf..e3b9905b5d 100644 --- a/.github/workflows/unassign-inactive-assignees.yml +++ b/.github/workflows/unassign-inactive-assignees.yml @@ -40,13 +40,13 @@ jobs: steps: - name: 'Generate GitHub App Token' id: 'generate_token' - uses: 'actions/create-github-app-token@v2' + uses: 'actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349' # ratchet:actions/create-github-app-token@v2 with: app-id: '${{ secrets.APP_ID }}' private-key: '${{ secrets.PRIVATE_KEY }}' - name: 'Unassign inactive assignees' - uses: 'actions/github-script@v7' + uses: 'actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b' # ratchet:actions/github-script@v7 env: DRY_RUN: '${{ inputs.dry_run }}' with: diff --git a/scripts/lint.js b/scripts/lint.js index 279421a979..6b814e26b2 100644 --- a/scripts/lint.js +++ b/scripts/lint.js @@ -394,6 +394,82 @@ export function runTSConfigLinter() { } } +export function runGithubActionsPinningLinter() { + console.log('\nRunning GitHub Actions pinning linter...'); + + let files = []; + try { + files = execSync( + "git ls-files '.github/workflows/*.yml' '.github/workflows/*.yaml' '.github/actions/**/*.yml' '.github/actions/**/*.yaml'", + ) + .toString() + .trim() + .split('\n') + .filter(Boolean); + } catch (e) { + console.error('Error finding GitHub Actions workflow files:', e.message); + process.exit(1); + } + + let violationsFound = false; + // Improved regex to capture action name and ref, handling optional quotes and comments. + const USES_PATTERN = /uses:\s*['"]?([^@\s'"]+)@([^#\s'"]+)['"]?/; + const SHA_PATTERN = /^[0-9a-f]{40}$/i; + + for (const file of files) { + if (!existsSync(file) || lstatSync(file).isDirectory()) { + continue; + } + const content = readFileSync(file, 'utf-8'); + const lines = content.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const match = line.match(USES_PATTERN); + if (match) { + const action = match[1]; + let ref = match[2]; + + // Clean up any trailing quotes that might have been captured + ref = ref.replace(/['"]$/, ''); + + // Skip local actions (starting with ./), docker actions, and explicit exclusions + if ( + action.startsWith('./') || + action.startsWith('docker://') || + line.includes('# github-actions-pinning:ignore') + ) { + continue; + } + + if (!SHA_PATTERN.test(ref)) { + violationsFound = true; + const lineNum = i + 1; + console.error( + `::error file=${file},line=${lineNum}::Action "${action}" uses "${ref}" instead of a 40-character SHA.`, + ); + } + } + } + } + + if (violationsFound) { + console.error(` +GitHub Actions pinning violations found. Please use exact commit hashes. + +To automatically fix these, you can use the "ratchet" tool (https://github.com/sethvargo/ratchet): + - Mac/Linux (Homebrew): brew install ratchet && ratchet pin .github/workflows/*.yml .github/actions/**/*.yml + - Other platforms: Download from GitHub releases and run "ratchet pin .github/workflows/*.yml .github/actions/**/*.yml" + +If you must use a tag, you can ignore this check by adding a comment (discouraged): + uses: some-action@v1 # github-actions-pinning:ignore +`); + process.exit(1); + } else { + console.log('No GitHub Actions pinning violations found.'); + } +} + function main() { const args = process.argv.slice(2); @@ -421,6 +497,9 @@ function main() { if (args.includes('--tsconfig')) { runTSConfigLinter(); } + if (args.includes('--check-github-actions-pinning')) { + runGithubActionsPinningLinter(); + } if (args.length === 0) { setupLinters(); @@ -431,6 +510,7 @@ function main() { runPrettier(); runSensitiveKeywordLinter(); runTSConfigLinter(); + runGithubActionsPinningLinter(); console.log('\nAll linting checks passed!'); } } From 8595b07f6da433f04f75fc2f8616c45b89e63899 Mon Sep 17 00:00:00 2001 From: nirali <124287834+Niralisj@users.noreply.github.com> Date: Thu, 26 Mar 2026 03:36:44 +0530 Subject: [PATCH 24/49] fix(cli): show helpful guidance when no skills are available (#23785) --- packages/cli/src/ui/components/views/SkillsList.test.tsx | 6 +++--- packages/cli/src/ui/components/views/SkillsList.tsx | 9 ++++++++- packages/cli/src/ui/constants.ts | 4 ++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/components/views/SkillsList.test.tsx b/packages/cli/src/ui/components/views/SkillsList.test.tsx index e6c85cc94d..6724c4e9f9 100644 --- a/packages/cli/src/ui/components/views/SkillsList.test.tsx +++ b/packages/cli/src/ui/components/views/SkillsList.test.tsx @@ -8,6 +8,7 @@ import { render } from '../../../test-utils/render.js'; import { describe, it, expect } from 'vitest'; import { SkillsList } from './SkillsList.js'; import { type SkillDefinition } from '@google/gemini-cli-core'; +import { SKILLS_DOCS_URL } from '../../constants.js'; describe('SkillsList Component', () => { const mockSkills: SkillDefinition[] = [ @@ -74,9 +75,8 @@ describe('SkillsList Component', () => { , ); const output = lastFrame(); - - expect(output).toContain('No skills available'); - + expect(output).toContain('No skills available.'); + expect(output).toContain(`Learn how to add skills: ${SKILLS_DOCS_URL}`); unmount(); }); diff --git a/packages/cli/src/ui/components/views/SkillsList.tsx b/packages/cli/src/ui/components/views/SkillsList.tsx index 64e2d3efd7..d6b681a94e 100644 --- a/packages/cli/src/ui/components/views/SkillsList.tsx +++ b/packages/cli/src/ui/components/views/SkillsList.tsx @@ -8,6 +8,7 @@ import type React from 'react'; import { Box, Text } from 'ink'; import { theme } from '../../semantic-colors.js'; import { type SkillDefinition } from '../../types.js'; +import { SKILLS_DOCS_URL } from '../../constants.js'; interface SkillsListProps { skills: readonly SkillDefinition[]; @@ -86,7 +87,13 @@ export const SkillsList: React.FC = ({ )} {skills.length === 0 && ( - No skills available + + No skills available. + + Learn how to add skills: + {SKILLS_DOCS_URL} + + )} ); diff --git a/packages/cli/src/ui/constants.ts b/packages/cli/src/ui/constants.ts index db52be1105..943f180134 100644 --- a/packages/cli/src/ui/constants.ts +++ b/packages/cli/src/ui/constants.ts @@ -58,3 +58,7 @@ export const MIN_TERMINAL_WIDTH_FOR_FULL_LABEL = 100; /** Default context usage fraction at which to trigger compression */ export const DEFAULT_COMPRESSION_THRESHOLD = 0.5; + +/** Documentation URL for skills setup and configuration */ +export const SKILLS_DOCS_URL = + 'https://github.com/google-gemini/gemini-cli/blob/main/docs/cli/skills.md'; From 20fd405f9c6baf4148920d8e475a59684c880269 Mon Sep 17 00:00:00 2001 From: Steven Robertson Date: Wed, 25 Mar 2026 15:17:30 -0700 Subject: [PATCH 25/49] fix: Chat logs and errors handle tail tool calls correctly (#22460) Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com> --- packages/core/src/core/geminiChat.test.ts | 82 +++++++++++++++++++ packages/core/src/core/geminiChat.ts | 4 +- packages/core/src/scheduler/scheduler.test.ts | 25 ++++++ packages/core/src/scheduler/scheduler.ts | 4 +- .../core/src/scheduler/state-manager.test.ts | 15 ++++ packages/core/src/scheduler/state-manager.ts | 2 +- .../core/src/scheduler/tool-executor.test.ts | 47 +++++++++++ packages/core/src/scheduler/tool-executor.ts | 4 +- packages/core/src/scheduler/types.ts | 6 +- 9 files changed, 181 insertions(+), 8 deletions(-) diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 925b0cfe5d..adc50d5979 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -19,6 +19,11 @@ import { SYNTHETIC_THOUGHT_SIGNATURE, type StreamEvent, } from './geminiChat.js'; +import { + type CompletedToolCall, + CoreToolCallStatus, +} from '../scheduler/types.js'; +import { MockTool } from '../test-utils/mock-tool.js'; import type { Config } from '../config/config.js'; import { setSimulate429 } from '../utils/testUtils.js'; import { DEFAULT_THINKING_MODE } from '../config/models.js'; @@ -165,6 +170,9 @@ describe('GeminiChat', () => { getToolRegistry: vi.fn().mockReturnValue({ getTool: vi.fn(), }), + toolRegistry: { + getTool: vi.fn(), + }, getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator), getRetryFetchErrors: vi.fn().mockReturnValue(false), getMaxAttempts: vi.fn().mockReturnValue(10), @@ -2569,4 +2577,78 @@ describe('GeminiChat', () => { }); }); }); + + describe('recordCompletedToolCalls', () => { + it('should use originalRequestName and originalRequestArgs if present', () => { + const completedCall: CompletedToolCall = { + status: CoreToolCallStatus.Success, + request: { + callId: 'call-1', + name: 'tail-tool', + args: { tail: 'args' }, + originalRequestName: 'original-tool', + originalRequestArgs: { original: 'args' }, + isClientInitiated: false, + prompt_id: 'p1', + }, + response: { + callId: 'call-1', + responseParts: [{ text: 'response' }], + resultDisplay: undefined, + error: undefined, + errorType: undefined, + }, + tool: new MockTool({ name: 'mock-tool' }), + invocation: new MockTool({ name: 'mock-tool' }).build({ key: 'value' }), + }; + + const spy = vi.spyOn(chat.getChatRecordingService(), 'recordToolCalls'); + + chat.recordCompletedToolCalls('test-model', [completedCall]); + + expect(spy).toHaveBeenCalledWith('test-model', [ + expect.objectContaining({ + id: 'call-1', + name: 'original-tool', + args: { original: 'args' }, + result: [{ text: 'response' }], + }), + ]); + }); + + it('should fall back to request name and args if original are not present', () => { + const completedCall: CompletedToolCall = { + status: CoreToolCallStatus.Success, + request: { + callId: 'call-1', + name: 'tool-name', + args: { key: 'value' }, + isClientInitiated: false, + prompt_id: 'p1', + }, + response: { + callId: 'call-1', + responseParts: [{ text: 'response' }], + resultDisplay: undefined, + error: undefined, + errorType: undefined, + }, + tool: new MockTool({ name: 'mock-tool' }), + invocation: new MockTool({ name: 'mock-tool' }).build({ key: 'value' }), + }; + + const spy = vi.spyOn(chat.getChatRecordingService(), 'recordToolCalls'); + + chat.recordCompletedToolCalls('test-model', [completedCall]); + + expect(spy).toHaveBeenCalledWith('test-model', [ + expect.objectContaining({ + id: 'call-1', + name: 'tool-name', + args: { key: 'value' }, + result: [{ text: 'response' }], + }), + ]); + }); + }); }); diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index abea19022a..00ff64a398 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -1032,8 +1032,8 @@ export class GeminiChat { return { id: call.request.callId, - name: call.request.name, - args: call.request.args, + name: call.request.originalRequestName ?? call.request.name, + args: call.request.originalRequestArgs ?? call.request.args, result: call.response?.responseParts || null, status: call.status, timestamp: new Date().toISOString(), diff --git a/packages/core/src/scheduler/scheduler.test.ts b/packages/core/src/scheduler/scheduler.test.ts index d029d714d7..25b7f3f01a 100644 --- a/packages/core/src/scheduler/scheduler.test.ts +++ b/packages/core/src/scheduler/scheduler.test.ts @@ -669,6 +669,30 @@ describe('Scheduler (Orchestrator)', () => { ); }); + it('should use originalRequestName when generating an error response', async () => { + const error = new Error('Some error'); + vi.mocked(checkPolicy).mockRejectedValue(error); + + const tailReq = { ...req1, originalRequestName: 'original-tool-name' }; + await scheduler.schedule(tailReq, signal); + + expect(mockStateManager.updateStatus).toHaveBeenCalledWith( + 'call-1', + CoreToolCallStatus.Error, + expect.objectContaining({ + errorType: ToolErrorType.UNHANDLED_EXCEPTION, + responseParts: expect.arrayContaining([ + expect.objectContaining({ + functionResponse: expect.objectContaining({ + name: 'original-tool-name', + response: { error: 'Some error' }, + }), + }), + ]), + }), + ); + }); + it('should handle errors from checkPolicy (e.g. non-interactive ASK_USER)', async () => { const error = new Error('Not interactive'); vi.mocked(checkPolicy).mockRejectedValue(error); @@ -1131,6 +1155,7 @@ describe('Scheduler (Orchestrator)', () => { name: 'tool-b', args: { key: 'value' }, originalRequestName: 'test-tool', // Preserves original name + originalRequestArgs: req1.args, // Preserves original args }), tool: mockToolB, }), diff --git a/packages/core/src/scheduler/scheduler.ts b/packages/core/src/scheduler/scheduler.ts index f442118b8e..ea308a26f6 100644 --- a/packages/core/src/scheduler/scheduler.ts +++ b/packages/core/src/scheduler/scheduler.ts @@ -77,7 +77,7 @@ const createErrorResponse = ( { functionResponse: { id: request.callId, - name: request.name, + name: request.originalRequestName ?? request.name, response: { error: error.message }, }, }, @@ -766,6 +766,8 @@ export class Scheduler { name: tailRequest.name, args: tailRequest.args, originalRequestName, + originalRequestArgs: + result.request.originalRequestArgs ?? result.request.args, isClientInitiated: result.request.isClientInitiated, prompt_id: result.request.prompt_id, schedulerId: this.schedulerId, diff --git a/packages/core/src/scheduler/state-manager.test.ts b/packages/core/src/scheduler/state-manager.test.ts index ff69e0d207..5a51ec6ebf 100644 --- a/packages/core/src/scheduler/state-manager.test.ts +++ b/packages/core/src/scheduler/state-manager.test.ts @@ -44,6 +44,8 @@ describe('SchedulerStateManager', () => { const mockInvocation = { shouldConfirmExecute: vi.fn(), + execute: vi.fn(), + getDescription: vi.fn(), } as unknown as AnyToolInvocation; const createValidatingCall = ( @@ -610,6 +612,19 @@ describe('SchedulerStateManager', () => { expect(onUpdate).toHaveBeenCalledTimes(1); }); + it('should use originalRequestName when cancelling queued calls', () => { + const call = createValidatingCall('tail-1'); + call.request.originalRequestName = 'original-tool'; + stateManager.enqueue([call]); + + stateManager.cancelAllQueued('Batch cancel'); + + const completed = stateManager.completedBatch[0] as CancelledToolCall; + expect(completed.response.responseParts[0]?.functionResponse?.name).toBe( + 'original-tool', + ); + }); + it('should not notify if cancelAllQueued is called on an empty queue', () => { vi.mocked(onUpdate).mockClear(); stateManager.cancelAllQueued('Batch cancel'); diff --git a/packages/core/src/scheduler/state-manager.ts b/packages/core/src/scheduler/state-manager.ts index 093aaa7308..c524a139bd 100644 --- a/packages/core/src/scheduler/state-manager.ts +++ b/packages/core/src/scheduler/state-manager.ts @@ -517,7 +517,7 @@ export class SchedulerStateManager { { functionResponse: { id: call.request.callId, - name: call.request.name, + name: call.request.originalRequestName ?? call.request.name, response: { error: errorMessage }, }, }, diff --git a/packages/core/src/scheduler/tool-executor.test.ts b/packages/core/src/scheduler/tool-executor.test.ts index 6abd5c7476..d94877ef7f 100644 --- a/packages/core/src/scheduler/tool-executor.test.ts +++ b/packages/core/src/scheduler/tool-executor.test.ts @@ -332,6 +332,53 @@ describe('ToolExecutor', () => { expect(result.status).toBe(CoreToolCallStatus.Cancelled); }); + it('should return cancelled result and use originalRequestName when signal is aborted', async () => { + const mockTool = new MockTool({ + name: 'slowTool', + }); + const invocation = mockTool.build({}); + + // Mock executeToolWithHooks to simulate slow execution + vi.mocked(coreToolHookTriggers.executeToolWithHooks).mockImplementation( + async () => { + await new Promise((r) => setTimeout(r, 100)); + return { llmContent: 'Done', returnDisplay: 'Done' }; + }, + ); + + const scheduledCall: ScheduledToolCall = { + status: CoreToolCallStatus.Scheduled, + request: { + callId: 'call-4', + name: 'actualToolName', + originalRequestName: 'originalToolName', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-4', + }, + tool: mockTool, + invocation: invocation as unknown as AnyToolInvocation, + startTime: Date.now(), + }; + + const controller = new AbortController(); + const promise = executor.execute({ + call: scheduledCall, + signal: controller.signal, + onUpdateToolCall: vi.fn(), + }); + + controller.abort(); + const result = await promise; + + expect(result.status).toBe(CoreToolCallStatus.Cancelled); + if (result.status === CoreToolCallStatus.Cancelled) { + expect(result.response.responseParts[0]?.functionResponse?.name).toBe( + 'originalToolName', + ); + } + }); + it('should truncate large shell output', async () => { // 1. Setup Config for Truncation vi.spyOn(config, 'getTruncateToolOutputThreshold').mockReturnValue(10); diff --git a/packages/core/src/scheduler/tool-executor.ts b/packages/core/src/scheduler/tool-executor.ts index f13f8a8657..a761d3896f 100644 --- a/packages/core/src/scheduler/tool-executor.ts +++ b/packages/core/src/scheduler/tool-executor.ts @@ -307,7 +307,7 @@ export class ToolExecutor { outputFile = truncatedOutputFile; responseParts = convertToFunctionResponse( - call.request.name, + call.request.originalRequestName ?? call.request.name, call.request.callId, output, this.config.getActiveModel(), @@ -325,7 +325,7 @@ export class ToolExecutor { { functionResponse: { id: call.request.callId, - name: call.request.name, + name: call.request.originalRequestName ?? call.request.name, response: { error: errorMessage }, }, }, diff --git a/packages/core/src/scheduler/types.ts b/packages/core/src/scheduler/types.ts index a9cde87d27..170aab67ca 100644 --- a/packages/core/src/scheduler/types.ts +++ b/packages/core/src/scheduler/types.ts @@ -37,10 +37,12 @@ export interface ToolCallRequestInfo { name: string; args: Record; /** - * The original name of the tool requested by the model. - * This is used for tail calls to ensure the final response retains the original name. + * The original name and arguments of the tool requested by the model. + * This is used for tail calls to ensure the final response and log retains + * the original values. */ originalRequestName?: string; + originalRequestArgs?: Record; isClientInitiated: boolean; prompt_id: string; checkpoint?: string; From b91758bf6b7c0799382978f05ce98a61a7c1a60e Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Wed, 25 Mar 2026 22:27:17 +0000 Subject: [PATCH 26/49] Don't try removing a tag from a non-existent release. (#23830) --- .github/actions/publish-release/action.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/actions/publish-release/action.yml b/.github/actions/publish-release/action.yml index a9e33f36eb..a7df2039d5 100644 --- a/.github/actions/publish-release/action.yml +++ b/.github/actions/publish-release/action.yml @@ -221,7 +221,9 @@ runs: --dry-run="${INPUTS_DRY_RUN}" \ --workspace="${INPUTS_CLI_PACKAGE_NAME}" \ --no-tag - npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} false + if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then + npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} false + fi - name: 'Get a2a-server Token' uses: './.github/actions/npm-auth-token' From a86935b6de1e6e532c2c7441b0702bb3891dc9a7 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Wed, 25 Mar 2026 16:26:34 -0700 Subject: [PATCH 27/49] fix(cli): allow ask question dialog to take full window height (#23693) --- packages/cli/src/test-utils/render.tsx | 2 + packages/cli/src/ui/App.test.tsx | 4 +- packages/cli/src/ui/AppContainer.tsx | 31 ++- .../src/ui/components/AskUserDialog.test.tsx | 43 +++++ .../cli/src/ui/components/AskUserDialog.tsx | 8 +- .../src/ui/components/MainContent.test.tsx | 177 +++++++++++++++++- .../__snapshots__/MainContent.test.tsx.snap | 37 ++++ .../components/messages/ToolGroupMessage.tsx | 35 ++-- .../ToolGroupMessageRegression.test.tsx | 160 ++++++++++++++++ .../cli/src/ui/contexts/UIStateContext.tsx | 2 +- packages/cli/src/ui/hooks/useGeminiStream.ts | 46 ++++- .../src/ui/layouts/DefaultAppLayout.test.tsx | 2 +- 12 files changed, 494 insertions(+), 53 deletions(-) create mode 100644 packages/cli/src/ui/components/messages/ToolGroupMessageRegression.test.tsx diff --git a/packages/cli/src/test-utils/render.tsx b/packages/cli/src/test-utils/render.tsx index 9dd0f96758..c4aec2e9cd 100644 --- a/packages/cli/src/test-utils/render.tsx +++ b/packages/cli/src/test-utils/render.tsx @@ -524,6 +524,8 @@ const baseMockUiState = { nightly: false, updateInfo: null, pendingHistoryItems: [], + mainControlsRef: () => {}, + rootUiRef: { current: null }, }; export const mockAppState: AppState = { diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index 950363f6a8..b836202eb7 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -70,9 +70,7 @@ describe('App', () => { cleanUiDetailsVisible: true, quittingMessages: null, dialogsVisible: false, - mainControlsRef: { - current: null, - } as unknown as React.MutableRefObject, + mainControlsRef: vi.fn(), rootUiRef: { current: null, } as unknown as React.MutableRefObject, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index ce5fc7c872..d58ed45d89 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -14,7 +14,7 @@ import { } from 'react'; import { type DOMElement, - measureElement, + ResizeObserver, useApp, useStdout, useStdin, @@ -397,7 +397,6 @@ export const AppContainer = (props: AppContainerProps) => { const branchName = useGitBranchName(config.getTargetDir()); // Layout measurements - const mainControlsRef = useRef(null); // For performance profiling only const rootUiRef = useRef(null); const lastTitleRef = useRef(null); @@ -1396,6 +1395,7 @@ Logging in with Google... Restarting Gemini CLI to continue. !proQuotaRequest && !copyModeEnabled; + const observerRef = useRef(null); const [controlsHeight, setControlsHeight] = useState(0); const [lastNonCopyControlsHeight, setLastNonCopyControlsHeight] = useState(0); @@ -1410,15 +1410,26 @@ Logging in with Google... Restarting Gemini CLI to continue. ? lastNonCopyControlsHeight : controlsHeight; - useLayoutEffect(() => { - if (mainControlsRef.current) { - const fullFooterMeasurement = measureElement(mainControlsRef.current); - const roundedHeight = Math.round(fullFooterMeasurement.height); - if (roundedHeight > 0 && roundedHeight !== controlsHeight) { - setControlsHeight(roundedHeight); - } + const mainControlsRef = useCallback((node: DOMElement | null) => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; } - }, [buffer, terminalWidth, terminalHeight, controlsHeight, isInputActive]); + + if (node) { + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (entry) { + const roundedHeight = Math.round(entry.contentRect.height); + setControlsHeight((prev) => + roundedHeight !== prev ? roundedHeight : prev, + ); + } + }); + observer.observe(node); + observerRef.current = observer; + } + }, []); // Compute available terminal height based on stable controls measurement const availableTerminalHeight = Math.max( diff --git a/packages/cli/src/ui/components/AskUserDialog.test.tsx b/packages/cli/src/ui/components/AskUserDialog.test.tsx index 53c820f69e..4f1cca7d8c 100644 --- a/packages/cli/src/ui/components/AskUserDialog.test.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.test.tsx @@ -1491,4 +1491,47 @@ describe('AskUserDialog', () => { expect(frame).toContain('3. Option 3'); }); }); + + it('allows the question to exceed 15 lines in a tall terminal', async () => { + const longQuestion = Array.from( + { length: 25 }, + (_, i) => `Line ${i + 1}`, + ).join('\n'); + const questions: Question[] = [ + { + question: longQuestion, + header: 'Tall Test', + type: QuestionType.CHOICE, + options: [ + { label: 'Option 1', description: 'D1' }, + { label: 'Option 2', description: 'D2' }, + { label: 'Option 3', description: 'D3' }, + ], + multiSelect: false, + unconstrainedHeight: false, + }, + ]; + + const { lastFrame, waitUntilReady } = await renderWithProviders( + , + { width: 80 }, + ); + + await waitFor(async () => { + await waitUntilReady(); + const frame = lastFrame(); + // Should show more than 15 lines of the question + // (The limit was previously 15, so showing Line 20 proves it's working) + expect(frame).toContain('Line 20'); + expect(frame).toContain('Line 25'); + // Should still show the options + expect(frame).toContain('1. Option 1'); + }); + }); }); diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx index cbb505320c..483fcb5055 100644 --- a/packages/cli/src/ui/components/AskUserDialog.tsx +++ b/packages/cli/src/ui/components/AskUserDialog.tsx @@ -855,13 +855,7 @@ const ChoiceQuestionView: React.FC = ({ listHeight && !isAlternateBuffer ? question.unconstrainedHeight ? Math.max(1, listHeight - selectionItems.length * 2) - : Math.min( - 15, - Math.max( - 1, - listHeight - Math.max(DIALOG_PADDING, reservedListHeight), - ), - ) + : Math.max(1, listHeight - Math.max(DIALOG_PADDING, reservedListHeight)) : undefined; const maxItemsToShow = diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index e5d74b5cf5..b6bc0795eb 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -21,6 +21,10 @@ import { type UIState, } from '../contexts/UIStateContext.js'; import { type IndividualToolCallDisplay } from '../types.js'; +import { + type ConfirmingToolState, + useConfirmingTool, +} from '../hooks/useConfirmingTool.js'; // Mock dependencies const mockUseSettings = vi.fn().mockReturnValue({ @@ -53,6 +57,10 @@ vi.mock('../hooks/useAlternateBuffer.js', () => ({ useAlternateBuffer: vi.fn(), })); +vi.mock('../hooks/useConfirmingTool.js', () => ({ + useConfirmingTool: vi.fn(), +})); + vi.mock('./AppHeader.js', () => ({ AppHeader: ({ showDetails = true }: { showDetails?: boolean }) => ( {showDetails ? 'AppHeader(full)' : 'AppHeader(minimal)'} @@ -503,6 +511,54 @@ describe('MainContent', () => { unmount(); }); + it('renders a subagent with a complete box including bottom border', async () => { + const subagentCall = { + callId: 'subagent-1', + name: 'codebase_investigator', + description: 'Investigating codebase', + status: CoreToolCallStatus.Executing, + kind: 'agent', + resultDisplay: { + isSubagentProgress: true, + agentName: 'codebase_investigator', + recentActivity: [ + { + id: '1', + type: 'tool_call', + content: 'run_shell_command', + args: '{"command": "echo hello"}', + status: 'running', + }, + ], + state: 'running', + }, + } as Partial as IndividualToolCallDisplay; + + const uiState = { + ...defaultMockUiState, + history: [{ id: 1, type: 'user', text: 'Investigate' }], + pendingHistoryItems: [ + { + type: 'tool_group' as const, + tools: [subagentCall], + borderBottom: true, + }, + ], + }; + + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: uiState as Partial, + config: makeFakeConfig({ useAlternateBuffer: false }), + }); + + await waitFor(() => { + expect(lastFrame()).toContain('codebase_investigator'); + }); + + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + it('renders a split tool group without a gap between static and pending areas', async () => { const toolCalls = [ { @@ -547,13 +603,124 @@ describe('MainContent', () => { const { lastFrame, unmount } = await renderWithProviders(, { uiState: uiState as Partial, }); - const output = lastFrame(); - // Verify Part 1 and Part 2 are rendered. - expect(output).toContain('Part 1'); - expect(output).toContain('Part 2'); + + await waitFor(() => { + const output = lastFrame(); + // Verify Part 1 and Part 2 are rendered. + expect(output).toContain('Part 1'); + expect(output).toContain('Part 2'); + }); // The snapshot will be the best way to verify there is no gap (empty line) between them. - expect(output).toMatchSnapshot(); + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('renders a ToolConfirmationQueue without an extra line when preceded by hidden tools', async () => { + const { ApprovalMode, WRITE_FILE_DISPLAY_NAME } = await import( + '@google/gemini-cli-core' + ); + const hiddenToolCalls = [ + { + callId: 'tool-hidden', + name: WRITE_FILE_DISPLAY_NAME, + approvalMode: ApprovalMode.PLAN, + status: CoreToolCallStatus.Success, + resultDisplay: 'Hidden content', + } as Partial as IndividualToolCallDisplay, + ]; + + const confirmingTool = { + tool: { + callId: 'call-1', + name: 'exit_plan_mode', + status: CoreToolCallStatus.AwaitingApproval, + confirmationDetails: { + type: 'exit_plan_mode' as const, + planPath: '/path/to/plan', + }, + }, + index: 1, + total: 1, + }; + + const uiState = { + ...defaultMockUiState, + history: [{ id: 1, type: 'user', text: 'Apply plan' }], + pendingHistoryItems: [ + { + type: 'tool_group' as const, + tools: hiddenToolCalls, + borderBottom: true, + }, + ], + }; + + // We need to mock useConfirmingTool to return our confirmingTool + vi.mocked(useConfirmingTool).mockReturnValue( + confirmingTool as unknown as ConfirmingToolState, + ); + + mockUseSettings.mockReturnValue( + createMockSettings({ + security: { enablePermanentToolApproval: true }, + ui: { errorVerbosity: 'full' }, + }), + ); + + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: uiState as Partial, + config: makeFakeConfig({ useAlternateBuffer: false }), + }); + + await waitFor(() => { + const output = lastFrame(); + // The output should NOT contain 'Hidden content' + expect(output).not.toContain('Hidden content'); + // The output should contain the confirmation header + expect(output).toContain('Ready to start implementation?'); + }); + + // Snapshot will reveal if there are extra blank lines + expect(lastFrame()).toMatchSnapshot(); + unmount(); + }); + + it('renders a spurious line when a tool group has only hidden tools and borderBottom true', async () => { + const { ApprovalMode, WRITE_FILE_DISPLAY_NAME } = await import( + '@google/gemini-cli-core' + ); + const uiState = { + ...defaultMockUiState, + history: [{ id: 1, type: 'user', text: 'Apply plan' }], + pendingHistoryItems: [ + { + type: 'tool_group' as const, + tools: [ + { + callId: 'tool-1', + name: WRITE_FILE_DISPLAY_NAME, + approvalMode: ApprovalMode.PLAN, + status: CoreToolCallStatus.Success, + resultDisplay: 'hidden', + } as Partial as IndividualToolCallDisplay, + ], + borderBottom: true, + }, + ], + }; + + const { lastFrame, unmount } = await renderWithProviders(, { + uiState: uiState as Partial, + config: makeFakeConfig({ useAlternateBuffer: false }), + }); + + await waitFor(() => { + expect(lastFrame()).toContain('Apply plan'); + }); + + // This snapshot will show no spurious line because the group is now correctly suppressed. + expect(lastFrame()).toMatchSnapshot(); unmount(); }); diff --git a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap index d5173e8c9c..0e8e29e54d 100644 --- a/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/MainContent.test.tsx.snap @@ -91,6 +91,19 @@ exports[`MainContent > MainContent Tool Output Height Logic > 'Normal mode - Unc " `; +exports[`MainContent > renders a ToolConfirmationQueue without an extra line when preceded by hidden tools 1`] = ` +"AppHeader(full) +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > Apply plan +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +╭──────────────────────────────────────────────────────────────────────────────╮ +│ Ready to start implementation? │ +│ │ +│ Error reading plan: Storage must be initialized before use │ +╰──────────────────────────────────────────────────────────────────────────────╯ +" +`; + exports[`MainContent > renders a split tool group without a gap between static and pending areas 1`] = ` "AppHeader(full) ╭──────────────────────────────────────────────────────────────────────────╮ @@ -105,6 +118,30 @@ exports[`MainContent > renders a split tool group without a gap between static a " `; +exports[`MainContent > renders a spurious line when a tool group has only hidden tools and borderBottom true 1`] = ` +"AppHeader(full) +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > Apply plan +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +" +`; + +exports[`MainContent > renders a subagent with a complete box including bottom border 1`] = ` +"AppHeader(full) +▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + > Investigate +▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ +╭──────────────────────────────────────────────────────────────────────────╮ +│ ≡ Running Agent... (ctrl+o to collapse) │ +│ │ +│ Running subagent codebase_investigator... │ +│ │ +│ ⠋ run_shell_command echo hello │ +│ │ +╰──────────────────────────────────────────────────────────────────────────╯ +" +`; + exports[`MainContent > renders mixed history items (user + gemini) with single line padding between them 1`] = ` "ScrollableList AppHeader(full) diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx index 69da3a1029..637e8afa40 100644 --- a/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolGroupMessage.tsx @@ -172,12 +172,10 @@ export const ToolGroupMessage: React.FC = ({ // If all tools are filtered out (e.g., in-progress AskUser tools, low-verbosity // internal errors, plan-mode hidden write/edit), we should not emit standalone // border fragments. The only case where an empty group should render is the - // explicit "closing slice" (tools: []) used to bridge static/pending sections. + // explicit "closing slice" (tools: []) used to bridge static/pending sections, + // and only if it's actually continuing an open box from above. const isExplicitClosingSlice = allToolCalls.length === 0; - if ( - visibleToolCalls.length === 0 && - (!isExplicitClosingSlice || borderBottomOverride !== true) - ) { + if (visibleToolCalls.length === 0 && !isExplicitClosingSlice) { return null; } @@ -269,19 +267,20 @@ export const ToolGroupMessage: React.FC = ({ We have to keep the bottom border separate so it doesn't get drawn over by the sticky header directly inside it. */ - (visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && ( - - ) + (visibleToolCalls.length > 0 || borderBottomOverride !== undefined) && + borderBottomOverride !== false && ( + + ) } ); diff --git a/packages/cli/src/ui/components/messages/ToolGroupMessageRegression.test.tsx b/packages/cli/src/ui/components/messages/ToolGroupMessageRegression.test.tsx new file mode 100644 index 0000000000..96239fb720 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ToolGroupMessageRegression.test.tsx @@ -0,0 +1,160 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderWithProviders } from '../../../test-utils/render.js'; +import { describe, it, expect } from 'vitest'; +import { ToolGroupMessage } from './ToolGroupMessage.js'; +import { + makeFakeConfig, + CoreToolCallStatus, + ApprovalMode, + WRITE_FILE_DISPLAY_NAME, + Kind, +} from '@google/gemini-cli-core'; +import os from 'node:os'; +import { createMockSettings } from '../../../test-utils/settings.js'; +import type { IndividualToolCallDisplay } from '../../types.js'; + +describe('ToolGroupMessage Regression Tests', () => { + const baseMockConfig = makeFakeConfig({ + model: 'gemini-pro', + targetDir: os.tmpdir(), + }); + const fullVerbositySettings = createMockSettings({ + ui: { errorVerbosity: 'full' }, + }); + + const createToolCall = ( + overrides: Partial = {}, + ): IndividualToolCallDisplay => + ({ + callId: 'tool-123', + name: 'test-tool', + status: CoreToolCallStatus.Success, + ...overrides, + }) as IndividualToolCallDisplay; + + const createItem = (tools: IndividualToolCallDisplay[]) => ({ + id: 1, + type: 'tool_group' as const, + tools, + }); + + it('Plan Mode: suppresses phantom tool group (hidden tools)', async () => { + const toolCalls = [ + createToolCall({ + name: WRITE_FILE_DISPLAY_NAME, + approvalMode: ApprovalMode.PLAN, + status: CoreToolCallStatus.Success, + }), + ]; + const item = createItem(toolCalls); + + const { lastFrame, unmount } = await renderWithProviders( + , + { config: baseMockConfig, settings: fullVerbositySettings }, + ); + + expect(lastFrame({ allowEmpty: true })).toBe(''); + unmount(); + }); + + it('Agent Case: suppresses the bottom border box for ongoing agents (no vertical ticks)', async () => { + const toolCalls = [ + createToolCall({ + name: 'agent', + kind: Kind.Agent, + status: CoreToolCallStatus.Executing, + resultDisplay: { + isSubagentProgress: true, + agentName: 'TestAgent', + state: 'running', + recentActivity: [], + }, + }), + ]; + const item = createItem(toolCalls); + + const { lastFrame, unmount } = await renderWithProviders( + , + { config: baseMockConfig, settings: fullVerbositySettings }, + ); + + const output = lastFrame(); + expect(output).toContain('Running Agent...'); + // It should render side borders from the content + expect(output).toContain('│'); + // It should NOT render the bottom border box (no corners ╰ ╯) + expect(output).not.toContain('╰'); + expect(output).not.toContain('╯'); + unmount(); + }); + + it('Agent Case: renders a bottom border horizontal line for completed agents', async () => { + const toolCalls = [ + createToolCall({ + name: 'agent', + kind: Kind.Agent, + status: CoreToolCallStatus.Success, + resultDisplay: { + isSubagentProgress: true, + agentName: 'TestAgent', + state: 'completed', + recentActivity: [], + }, + }), + ]; + const item = createItem(toolCalls); + + const { lastFrame, unmount } = await renderWithProviders( + , + { config: baseMockConfig, settings: fullVerbositySettings }, + ); + + const output = lastFrame(); + // Verify it rendered subagent content + expect(output).toContain('Agent'); + // It should render the bottom horizontal line + expect(output).toContain( + '╰──────────────────────────────────────────────────────────────────────────╯', + ); + unmount(); + }); + + it('Bridges: still renders a bridge if it has a top border', async () => { + const toolCalls: IndividualToolCallDisplay[] = []; + const item = createItem(toolCalls); + + const { lastFrame, unmount } = await renderWithProviders( + , + { config: baseMockConfig, settings: fullVerbositySettings }, + ); + + expect(lastFrame({ allowEmpty: true })).not.toBe(''); + unmount(); + }); +}); diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index e4d95a79af..8447247e53 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -191,7 +191,7 @@ export interface UIState { sessionStats: SessionStatsState; terminalWidth: number; terminalHeight: number; - mainControlsRef: React.MutableRefObject; + mainControlsRef: React.RefCallback; // NOTE: This is for performance profiling only. rootUiRef: React.MutableRefObject; currentIDE: IdeInfo | null; diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 54006d2ab2..757c24f2c3 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -26,7 +26,6 @@ import { debugLogger, runInDevTraceSpan, EDIT_TOOL_NAMES, - ASK_USER_TOOL_NAME, processRestorableToolCalls, recordToolCallInteractions, ToolErrorType, @@ -40,6 +39,7 @@ import { isBackgroundExecutionData, Kind, ACTIVATE_SKILL_TOOL_NAME, + shouldHideToolCall, } from '@google/gemini-cli-core'; import type { Config, @@ -66,7 +66,12 @@ import type { SlashCommandProcessorResult, HistoryItemModel, } from '../types.js'; -import { StreamingState, MessageType } from '../types.js'; +import { + StreamingState, + MessageType, + mapCoreStatusToDisplayStatus, + ToolCallStatus, +} from '../types.js'; import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js'; import { useShellCommandProcessor } from './shellCommandProcessor.js'; import { handleAtCommand } from './atCommandProcessor.js'; @@ -541,14 +546,39 @@ export const useGeminiStream = ( const anyVisibleInHistory = pushedToolCallIds.size > 0; const anyVisibleInPending = remainingTools.some((tc) => { - // AskUser tools are rendered by AskUserDialog, not ToolGroupMessage - const isInProgress = - tc.status !== 'success' && - tc.status !== 'error' && - tc.status !== 'cancelled'; - if (tc.request.name === ASK_USER_TOOL_NAME && isInProgress) { + const displayName = tc.tool?.displayName ?? tc.request.name; + + let hasResultDisplay = false; + if ( + tc.status === CoreToolCallStatus.Success || + tc.status === CoreToolCallStatus.Error || + tc.status === CoreToolCallStatus.Cancelled + ) { + hasResultDisplay = !!tc.response?.resultDisplay; + } else if (tc.status === CoreToolCallStatus.Executing) { + hasResultDisplay = !!tc.liveOutput; + } + + // AskUser tools and Plan Mode write/edit are handled by this logic + if ( + shouldHideToolCall({ + displayName, + status: tc.status, + approvalMode: tc.approvalMode, + hasResultDisplay, + parentCallId: tc.request.parentCallId, + }) + ) { return false; } + + // ToolGroupMessage explicitly hides Confirming tools because they are + // rendered in the interactive ToolConfirmationQueue instead. + const displayStatus = mapCoreStatusToDisplayStatus(tc.status); + if (displayStatus === ToolCallStatus.Confirming) { + return false; + } + // ToolGroupMessage now shows all non-canceled tools, so they are visible // in pending and we need to draw the closing border for them. return true; diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx index 43b970da8e..7bf51b7d84 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.test.tsx @@ -25,7 +25,7 @@ const mockUIState = { dialogsVisible: false, streamingState: StreamingState.Idle, isBackgroundShellListOpen: false, - mainControlsRef: { current: null }, + mainControlsRef: vi.fn(), customDialog: null, historyManager: { addItem: vi.fn() }, history: [], From ae3dbab38a0a8e86fe6652267e4881fedf463ca8 Mon Sep 17 00:00:00 2001 From: Yuna Seol Date: Wed, 25 Mar 2026 19:34:18 -0400 Subject: [PATCH 28/49] fix(core): strip leading underscores from error types in telemetry (#23824) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- packages/core/src/utils/errors.test.ts | 9 +++++++++ packages/core/src/utils/errors.ts | 9 ++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/core/src/utils/errors.test.ts b/packages/core/src/utils/errors.test.ts index 81f9eb09a4..63aa4628fb 100644 --- a/packages/core/src/utils/errors.test.ts +++ b/packages/core/src/utils/errors.test.ts @@ -354,4 +354,13 @@ describe('getErrorType', () => { expect(getErrorType(null)).toBe('unknown'); expect(getErrorType(undefined)).toBe('unknown'); }); + + it('should strip leading underscores from error names', () => { + class _GaxiosError extends Error {} + expect(getErrorType(new _GaxiosError('test'))).toBe('GaxiosError'); + + const errorWithUnderscoreName = new Error('test'); + errorWithUnderscoreName.name = '_CodeBuddyError'; + expect(getErrorType(errorWithUnderscoreName)).toBe('CodeBuddyError'); + }); }); diff --git a/packages/core/src/utils/errors.ts b/packages/core/src/utils/errors.ts index a390abcdc4..834d1e4586 100644 --- a/packages/core/src/utils/errors.ts +++ b/packages/core/src/utils/errors.ts @@ -58,9 +58,12 @@ export function getErrorType(error: unknown): string { if (!(error instanceof Error)) return 'unknown'; // Return constructor name if the generic 'Error' name is used (for custom errors) - return error.name === 'Error' - ? (error.constructor?.name ?? 'Error') - : error.name; + const name = + error.name === 'Error' ? (error.constructor?.name ?? 'Error') : error.name; + + // Strip leading underscore from error names. Bundlers like esbuild sometimes + // rename classes to avoid scope collisions. + return name.replace(/^_+/, ''); } export class FatalError extends Error { From c1e4dbd157856b8ca290bb6d05f39f142c85a049 Mon Sep 17 00:00:00 2001 From: gemini-cli-robot Date: Wed, 25 Mar 2026 18:33:27 -0700 Subject: [PATCH 29/49] Changelog for v0.35.0 (#23819) Co-authored-by: g-samroberts <158088236+g-samroberts@users.noreply.github.com> Co-authored-by: g-samroberts --- docs/changelogs/index.md | 24 ++ docs/changelogs/latest.md | 825 +++++++++++++++++--------------------- 2 files changed, 386 insertions(+), 463 deletions(-) diff --git a/docs/changelogs/index.md b/docs/changelogs/index.md index d79bd910d1..84a0daa3b2 100644 --- a/docs/changelogs/index.md +++ b/docs/changelogs/index.md @@ -18,6 +18,30 @@ on GitHub. | [Preview](preview.md) | Experimental features ready for early feedback. | | [Stable](latest.md) | Stable, recommended for general use. | +## Announcements: v0.35.0 - 2026-03-24 + +- **Customizable Keyboard Shortcuts:** Users can now customize their keyboard + shortcuts, including support for literal character keybindings and the + extended Kitty protocol + ([#21945](https://github.com/google-gemini/gemini-cli/pull/21945), + [#21972](https://github.com/google-gemini/gemini-cli/pull/21972) by + @scidomino). +- **Vim Mode Improvements:** Added missing motions (X, ~, r, f/F/t/T) and + yank/paste support with the unnamed register + ([#21932](https://github.com/google-gemini/gemini-cli/pull/21932), + [#22026](https://github.com/google-gemini/gemini-cli/pull/22026) by @aanari). +- **Tool Isolation and Sandboxing:** Introduced `SandboxManager` to isolate + process-spawning tools and added Linux bubblewrap/seccomp sandboxing support + ([#21774](https://github.com/google-gemini/gemini-cli/pull/21774), + [#22231](https://github.com/google-gemini/gemini-cli/pull/22231) by @galz10, + [#22680](https://github.com/google-gemini/gemini-cli/pull/22680) by + @DavidAPierce). +- **JIT Context Discovery:** Implemented Just-In-Time context discovery for file + system tools to improve model performance and accuracy + ([#22082](https://github.com/google-gemini/gemini-cli/pull/22082), + [#22736](https://github.com/google-gemini/gemini-cli/pull/22736) by + @SandyTao520). + ## Announcements: v0.34.0 - 2026-03-17 - **Plan Mode Enabled by Default:** Plan Mode is now enabled by default to help diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md index e49ef1c652..8477a13e98 100644 --- a/docs/changelogs/latest.md +++ b/docs/changelogs/latest.md @@ -1,6 +1,6 @@ -# Latest stable release: v0.34.0 +# Latest stable release: v0.35.0 -Released: March 17, 2026 +Released: March 24, 2026 For most users, our latest stable release is the recommended release. Install the latest stable version with: @@ -11,474 +11,373 @@ npm install -g @google/gemini-cli ## Highlights -- **Plan Mode Enabled by Default**: The comprehensive planning capability is now - enabled by default, allowing for better structured task management and - execution. -- **Enhanced Sandboxing Capabilities**: Added support for native gVisor (runsc) - sandboxing as well as experimental LXC container sandboxing to provide more - robust and isolated execution environments. -- **Improved Loop Detection & Recovery**: Implemented iterative loop detection - and model feedback mechanisms to prevent the CLI from getting stuck in - repetitive actions. -- **Customizable UI Elements**: You can now configure a custom footer using the - new `/footer` command, and enjoy standardized semantic focus colors for better - history visibility. -- **Extensive Subagent Updates**: Refinements across the tracker visualization - tools, background process logging, and broader fallback support for models in - tool execution scenarios. +- **Customizable Keyboard Shortcuts:** Significant improvements to input + flexibility with support for custom keybindings, literal character bindings, + and extended terminal protocol keys. +- **Vim Mode Enhancements:** Further refinement of the Vim modal editing + experience, adding common motions like \`X\`, \`~\`, \`r\`, and \`f/F/t/T\`, + along with yank and paste support. +- **Enhanced Security through Sandboxing:** Introduction of a unified + \`SandboxManager\` and integration of Linux-native sandboxing (bubblewrap and + seccomp) to isolate tool execution and improve system security. +- **JIT Context Discovery:** Improved performance and accuracy by enabling + Just-In-Time context loading for file system tools, ensuring the model has the + most relevant information without overwhelming the context. +- **Subagent & Performance Updates:** Subagents are now enabled by default, + supported by a model-driven parallel tool scheduler and code splitting for + faster startup and more efficient task execution. ## What's Changed -- feat(cli): add chat resume footer on session quit by @lordshashank in - [#20667](https://github.com/google-gemini/gemini-cli/pull/20667) -- Support bold and other styles in svg snapshots by @jacob314 in - [#20937](https://github.com/google-gemini/gemini-cli/pull/20937) -- fix(core): increase A2A agent timeout to 30 minutes by @adamfweidman in - [#21028](https://github.com/google-gemini/gemini-cli/pull/21028) -- Cleanup old branches. by @jacob314 in - [#19354](https://github.com/google-gemini/gemini-cli/pull/19354) -- chore(release): bump version to 0.34.0-nightly.20260303.34f0c1538 by +- feat(cli): customizable keyboard shortcuts by @scidomino in + [#21945](https://github.com/google-gemini/gemini-cli/pull/21945) +- feat(core): Thread `AgentLoopContext` through core. by @joshualitt in + [#21944](https://github.com/google-gemini/gemini-cli/pull/21944) +- chore(release): bump version to 0.35.0-nightly.20260311.657f19c1f by @gemini-cli-robot in - [#21034](https://github.com/google-gemini/gemini-cli/pull/21034) -- feat(ui): standardize semantic focus colors and enhance history visibility by - @keithguerin in - [#20745](https://github.com/google-gemini/gemini-cli/pull/20745) -- fix: merge duplicate imports in packages/core (3/4) by @Nixxx19 in - [#20928](https://github.com/google-gemini/gemini-cli/pull/20928) -- Add extra safety checks for proto pollution by @jacob314 in - [#20396](https://github.com/google-gemini/gemini-cli/pull/20396) -- feat(core): Add tracker CRUD tools & visualization by @anj-s in - [#19489](https://github.com/google-gemini/gemini-cli/pull/19489) -- Revert "fix(ui): persist expansion in AskUser dialog when navigating options" - by @jacob314 in - [#21042](https://github.com/google-gemini/gemini-cli/pull/21042) -- Changelog for v0.33.0-preview.0 by @gemini-cli-robot in - [#21030](https://github.com/google-gemini/gemini-cli/pull/21030) -- fix: model persistence for all scenarios by @sripasg in - [#21051](https://github.com/google-gemini/gemini-cli/pull/21051) -- chore/release: bump version to 0.34.0-nightly.20260304.28af4e127 by - @gemini-cli-robot in - [#21054](https://github.com/google-gemini/gemini-cli/pull/21054) -- Consistently guard restarts against concurrent auto updates by @scidomino in - [#21016](https://github.com/google-gemini/gemini-cli/pull/21016) -- Defensive coding to reduce the risk of Maximum update depth errors by - @jacob314 in [#20940](https://github.com/google-gemini/gemini-cli/pull/20940) -- fix(cli): Polish shell autocomplete rendering to be a little more shell native - feeling. by @jacob314 in - [#20931](https://github.com/google-gemini/gemini-cli/pull/20931) -- Docs: Update plan mode docs by @jkcinouye in - [#19682](https://github.com/google-gemini/gemini-cli/pull/19682) -- fix(mcp): Notifications/tools/list_changed support not working by @jacob314 in - [#21050](https://github.com/google-gemini/gemini-cli/pull/21050) -- fix(cli): register extension lifecycle events in DebugProfiler by - @fayerman-source in - [#20101](https://github.com/google-gemini/gemini-cli/pull/20101) -- chore(dev): update vscode settings for typescriptreact by @rohit-4321 in - [#19907](https://github.com/google-gemini/gemini-cli/pull/19907) -- fix(cli): enable multi-arch docker builds for sandbox by @ru-aish in - [#19821](https://github.com/google-gemini/gemini-cli/pull/19821) -- Changelog for v0.32.0 by @gemini-cli-robot in - [#21033](https://github.com/google-gemini/gemini-cli/pull/21033) -- Changelog for v0.33.0-preview.1 by @gemini-cli-robot in - [#21058](https://github.com/google-gemini/gemini-cli/pull/21058) -- feat(core): improve @scripts/copy_files.js autocomplete to prioritize - filenames by @sehoon38 in - [#21064](https://github.com/google-gemini/gemini-cli/pull/21064) -- feat(sandbox): add experimental LXC container sandbox support by @h30s in - [#20735](https://github.com/google-gemini/gemini-cli/pull/20735) -- feat(evals): add overall pass rate row to eval nightly summary table by - @gundermanc in - [#20905](https://github.com/google-gemini/gemini-cli/pull/20905) -- feat(telemetry): include language in telemetry and fix accepted lines - computation by @gundermanc in - [#21126](https://github.com/google-gemini/gemini-cli/pull/21126) -- Changelog for v0.32.1 by @gemini-cli-robot in - [#21055](https://github.com/google-gemini/gemini-cli/pull/21055) -- feat(core): add robustness tests, logging, and metrics for CodeAssistServer - SSE parsing by @yunaseoul in - [#21013](https://github.com/google-gemini/gemini-cli/pull/21013) -- feat: add issue assignee workflow by @kartikangiras in - [#21003](https://github.com/google-gemini/gemini-cli/pull/21003) -- fix: improve error message when OAuth succeeds but project ID is required by - @Nixxx19 in [#21070](https://github.com/google-gemini/gemini-cli/pull/21070) -- feat(loop-reduction): implement iterative loop detection and model feedback by - @aishaneeshah in - [#20763](https://github.com/google-gemini/gemini-cli/pull/20763) -- chore(github): require prompt approvers for agent prompt files by @gundermanc - in [#20896](https://github.com/google-gemini/gemini-cli/pull/20896) -- Docs: Create tools reference by @jkcinouye in - [#19470](https://github.com/google-gemini/gemini-cli/pull/19470) -- fix(core, a2a-server): prevent hang during OAuth in non-interactive sessions - by @spencer426 in - [#21045](https://github.com/google-gemini/gemini-cli/pull/21045) -- chore(cli): enable deprecated settings removal by default by @yashodipmore in - [#20682](https://github.com/google-gemini/gemini-cli/pull/20682) -- feat(core): Disable fast ack helper for hints. by @joshualitt in - [#21011](https://github.com/google-gemini/gemini-cli/pull/21011) -- fix(ui): suppress redundant failure note when tool error note is shown by - @NTaylorMullen in - [#21078](https://github.com/google-gemini/gemini-cli/pull/21078) -- docs: document planning workflows with Conductor example by @jerop in - [#21166](https://github.com/google-gemini/gemini-cli/pull/21166) -- feat(release): ship esbuild bundle in npm package by @genneth in - [#19171](https://github.com/google-gemini/gemini-cli/pull/19171) -- fix(extensions): preserve symlinks in extension source path while enforcing - folder trust by @galz10 in - [#20867](https://github.com/google-gemini/gemini-cli/pull/20867) -- fix(cli): defer tool exclusions to policy engine in non-interactive mode by - @EricRahm in [#20639](https://github.com/google-gemini/gemini-cli/pull/20639) -- fix(ui): removed double padding on rendered content by @devr0306 in - [#21029](https://github.com/google-gemini/gemini-cli/pull/21029) -- fix(core): truncate excessively long lines in grep search output by - @gundermanc in - [#21147](https://github.com/google-gemini/gemini-cli/pull/21147) -- feat: add custom footer configuration via `/footer` by @jackwotherspoon in - [#19001](https://github.com/google-gemini/gemini-cli/pull/19001) -- perf(core): fix OOM crash in long-running sessions by @WizardsForgeGames in - [#19608](https://github.com/google-gemini/gemini-cli/pull/19608) -- refactor(cli): categorize built-in themes into dark/ and light/ directories by - @JayadityaGit in - [#18634](https://github.com/google-gemini/gemini-cli/pull/18634) -- fix(core): explicitly allow codebase_investigator and cli_help in read-only - mode by @Adib234 in - [#21157](https://github.com/google-gemini/gemini-cli/pull/21157) -- test: add browser agent integration tests by @kunal-10-cloud in - [#21151](https://github.com/google-gemini/gemini-cli/pull/21151) -- fix(cli): fix enabling kitty codes on Windows Terminal by @scidomino in - [#21136](https://github.com/google-gemini/gemini-cli/pull/21136) -- refactor(core): extract shared OAuth flow primitives from MCPOAuthProvider by - @SandyTao520 in - [#20895](https://github.com/google-gemini/gemini-cli/pull/20895) -- fix(ui): add partial output to cancelled shell UI by @devr0306 in - [#21178](https://github.com/google-gemini/gemini-cli/pull/21178) -- fix(cli): replace hardcoded keybinding strings with dynamic formatters by - @scidomino in [#21159](https://github.com/google-gemini/gemini-cli/pull/21159) -- DOCS: Update quota and pricing page by @g-samroberts in - [#21194](https://github.com/google-gemini/gemini-cli/pull/21194) -- feat(telemetry): implement Clearcut logging for startup statistics by - @yunaseoul in [#21172](https://github.com/google-gemini/gemini-cli/pull/21172) -- feat(triage): add area/documentation to issue triage by @g-samroberts in - [#21222](https://github.com/google-gemini/gemini-cli/pull/21222) -- Fix so shell calls are formatted by @jacob314 in - [#21237](https://github.com/google-gemini/gemini-cli/pull/21237) -- feat(cli): add native gVisor (runsc) sandboxing support by @Zheyuan-Lin in - [#21062](https://github.com/google-gemini/gemini-cli/pull/21062) -- docs: use absolute paths for internal links in plan-mode.md by @jerop in - [#21299](https://github.com/google-gemini/gemini-cli/pull/21299) -- fix(core): prevent unhandled AbortError crash during stream loop detection by - @7hokerz in [#21123](https://github.com/google-gemini/gemini-cli/pull/21123) -- fix:reorder env var redaction checks to scan values first by @kartikangiras in - [#21059](https://github.com/google-gemini/gemini-cli/pull/21059) -- fix(acp): rename --experimental-acp to --acp & remove Zed-specific refrences - by @skeshive in - [#21171](https://github.com/google-gemini/gemini-cli/pull/21171) -- feat(core): fallback to 2.5 models with no access for toolcalls by @sehoon38 - in [#21283](https://github.com/google-gemini/gemini-cli/pull/21283) -- test(core): improve testing for API request/response parsing by @sehoon38 in - [#21227](https://github.com/google-gemini/gemini-cli/pull/21227) -- docs(links): update docs-writer skill and fix broken link by @g-samroberts in - [#21314](https://github.com/google-gemini/gemini-cli/pull/21314) -- Fix code colorizer ansi escape bug. by @jacob314 in - [#21321](https://github.com/google-gemini/gemini-cli/pull/21321) -- remove wildcard behavior on keybindings by @scidomino in - [#21315](https://github.com/google-gemini/gemini-cli/pull/21315) -- feat(acp): Add support for AI Gateway auth by @skeshive in - [#21305](https://github.com/google-gemini/gemini-cli/pull/21305) -- fix(theme): improve theme color contrast for macOS Terminal.app by @clocky in - [#21175](https://github.com/google-gemini/gemini-cli/pull/21175) -- feat (core): Implement tracker related SI changes by @anj-s in - [#19964](https://github.com/google-gemini/gemini-cli/pull/19964) -- Changelog for v0.33.0-preview.2 by @gemini-cli-robot in - [#21333](https://github.com/google-gemini/gemini-cli/pull/21333) -- Changelog for v0.33.0-preview.3 by @gemini-cli-robot in - [#21347](https://github.com/google-gemini/gemini-cli/pull/21347) -- docs: format release times as HH:MM UTC by @pavan-sh in - [#20726](https://github.com/google-gemini/gemini-cli/pull/20726) -- fix(cli): implement --all flag for extensions uninstall by @sehoon38 in - [#21319](https://github.com/google-gemini/gemini-cli/pull/21319) -- docs: fix incorrect relative links to command reference by @kanywst in - [#20964](https://github.com/google-gemini/gemini-cli/pull/20964) -- documentiong ensures ripgrep by @Jatin24062005 in - [#21298](https://github.com/google-gemini/gemini-cli/pull/21298) -- fix(core): handle AbortError thrown during processTurn by @MumuTW in - [#21296](https://github.com/google-gemini/gemini-cli/pull/21296) -- docs(cli): clarify ! command output visibility in shell commands tutorial by - @MohammedADev in - [#21041](https://github.com/google-gemini/gemini-cli/pull/21041) -- fix: logic for task tracker strategy and remove tracker tools by @anj-s in - [#21355](https://github.com/google-gemini/gemini-cli/pull/21355) -- fix(partUtils): display media type and size for inline data parts by @Aboudjem - in [#21358](https://github.com/google-gemini/gemini-cli/pull/21358) -- Fix(accessibility): add screen reader support to RewindViewer by @Famous077 in - [#20750](https://github.com/google-gemini/gemini-cli/pull/20750) -- fix(hooks): propagate stopHookActive in AfterAgent retry path (#20426) by - @Aarchi-07 in [#20439](https://github.com/google-gemini/gemini-cli/pull/20439) -- fix(core): deduplicate GEMINI.md files by device/inode on case-insensitive - filesystems (#19904) by @Nixxx19 in - [#19915](https://github.com/google-gemini/gemini-cli/pull/19915) -- feat(core): add concurrency safety guidance for subagent delegation (#17753) - by @abhipatel12 in - [#21278](https://github.com/google-gemini/gemini-cli/pull/21278) -- feat(ui): dynamically generate all keybinding hints by @scidomino in - [#21346](https://github.com/google-gemini/gemini-cli/pull/21346) -- feat(core): implement unified KeychainService and migrate token storage by - @ehedlund in [#21344](https://github.com/google-gemini/gemini-cli/pull/21344) -- fix(cli): gracefully handle --resume when no sessions exist by @SandyTao520 in - [#21429](https://github.com/google-gemini/gemini-cli/pull/21429) -- fix(plan): keep approved plan during chat compression by @ruomengz in - [#21284](https://github.com/google-gemini/gemini-cli/pull/21284) -- feat(core): implement generic CacheService and optimize setupUser by @sehoon38 - in [#21374](https://github.com/google-gemini/gemini-cli/pull/21374) -- Update quota and pricing documentation with subscription tiers by @srithreepo - in [#21351](https://github.com/google-gemini/gemini-cli/pull/21351) -- fix(core): append correct OTLP paths for HTTP exporters by - @sebastien-prudhomme in - [#16836](https://github.com/google-gemini/gemini-cli/pull/16836) -- Changelog for v0.33.0-preview.4 by @gemini-cli-robot in - [#21354](https://github.com/google-gemini/gemini-cli/pull/21354) -- feat(cli): implement dot-prefixing for slash command conflicts by @ehedlund in - [#20979](https://github.com/google-gemini/gemini-cli/pull/20979) -- refactor(core): standardize MCP tool naming to mcp\_ FQN format by - @abhipatel12 in - [#21425](https://github.com/google-gemini/gemini-cli/pull/21425) -- feat(cli): hide gemma settings from display and mark as experimental by - @abhipatel12 in - [#21471](https://github.com/google-gemini/gemini-cli/pull/21471) -- feat(skills): refine string-reviewer guidelines and description by @clocky in - [#20368](https://github.com/google-gemini/gemini-cli/pull/20368) -- fix(core): whitelist TERM and COLORTERM in environment sanitization by - @deadsmash07 in - [#20514](https://github.com/google-gemini/gemini-cli/pull/20514) -- fix(billing): fix overage strategy lifecycle and settings integration by - @gsquared94 in - [#21236](https://github.com/google-gemini/gemini-cli/pull/21236) -- fix: expand paste placeholders in TextInput on submit by @Jefftree in - [#19946](https://github.com/google-gemini/gemini-cli/pull/19946) -- fix(core): add in-memory cache to ChatRecordingService to prevent OOM by - @SandyTao520 in - [#21502](https://github.com/google-gemini/gemini-cli/pull/21502) -- feat(cli): overhaul thinking UI by @keithguerin in - [#18725](https://github.com/google-gemini/gemini-cli/pull/18725) -- fix(ui): unify Ctrl+O expansion hint experience across buffer modes by - @jwhelangoog in - [#21474](https://github.com/google-gemini/gemini-cli/pull/21474) -- fix(cli): correct shell height reporting by @jacob314 in - [#21492](https://github.com/google-gemini/gemini-cli/pull/21492) -- Make test suite pass when the GEMINI_SYSTEM_MD env variable or - GEMINI_WRITE_SYSTEM_MD variable happens to be set locally/ by @jacob314 in - [#21480](https://github.com/google-gemini/gemini-cli/pull/21480) -- Disallow underspecified types by @gundermanc in - [#21485](https://github.com/google-gemini/gemini-cli/pull/21485) -- refactor(cli): standardize on 'reload' verb for all components by @keithguerin - in [#20654](https://github.com/google-gemini/gemini-cli/pull/20654) -- feat(cli): Invert quota language to 'percent used' by @keithguerin in - [#20100](https://github.com/google-gemini/gemini-cli/pull/20100) -- Docs: Add documentation for notifications (experimental)(macOS) by @jkcinouye - in [#21163](https://github.com/google-gemini/gemini-cli/pull/21163) -- Code review comments as a pr by @jacob314 in - [#21209](https://github.com/google-gemini/gemini-cli/pull/21209) -- feat(cli): unify /chat and /resume command UX by @LyalinDotCom in - [#20256](https://github.com/google-gemini/gemini-cli/pull/20256) -- docs: fix typo 'allowslisted' -> 'allowlisted' in mcp-server.md by + [#21966](https://github.com/google-gemini/gemini-cli/pull/21966) +- refactor(a2a): remove legacy CoreToolScheduler by @adamfweidman in + [#21955](https://github.com/google-gemini/gemini-cli/pull/21955) +- feat(ui): add missing vim mode motions (X, ~, r, f/F/t/T, df/dt and friends) + by @aanari in [#21932](https://github.com/google-gemini/gemini-cli/pull/21932) +- Feat/retry fetch notifications by @aishaneeshah in + [#21813](https://github.com/google-gemini/gemini-cli/pull/21813) +- fix(core): remove OAuth check from handle fallback and clean up stray file by + @sehoon38 in [#21962](https://github.com/google-gemini/gemini-cli/pull/21962) +- feat(cli): support literal character keybindings and extended Kitty protocol + keys by @scidomino in + [#21972](https://github.com/google-gemini/gemini-cli/pull/21972) +- fix(ui): clamp cursor to last char after all NORMAL mode deletes by @aanari in + [#21973](https://github.com/google-gemini/gemini-cli/pull/21973) +- test(core): add missing tests for prompts/utils.ts by @krrishverma1805-web in + [#19941](https://github.com/google-gemini/gemini-cli/pull/19941) +- fix(cli): allow scrolling keys in copy mode (Ctrl+S selection mode) by + @nsalerni in [#19933](https://github.com/google-gemini/gemini-cli/pull/19933) +- docs(cli): add custom keybinding documentation by @scidomino in + [#21980](https://github.com/google-gemini/gemini-cli/pull/21980) +- docs: fix misleading YOLO mode description in defaultApprovalMode by @Gyanranjan-Priyam in - [#21665](https://github.com/google-gemini/gemini-cli/pull/21665) -- fix(core): display actual graph output in tracker_visualize tool by @anj-s in - [#21455](https://github.com/google-gemini/gemini-cli/pull/21455) -- fix(core): sanitize SSE-corrupted JSON and domain strings in error - classification by @gsquared94 in - [#21702](https://github.com/google-gemini/gemini-cli/pull/21702) -- Docs: Make documentation links relative by @diodesign in - [#21490](https://github.com/google-gemini/gemini-cli/pull/21490) -- feat(cli): expose /tools desc as explicit subcommand for discoverability by - @aworki in [#21241](https://github.com/google-gemini/gemini-cli/pull/21241) -- feat(cli): add /compact alias for /compress command by @jackwotherspoon in - [#21711](https://github.com/google-gemini/gemini-cli/pull/21711) -- feat(plan): enable Plan Mode by default by @jerop in - [#21713](https://github.com/google-gemini/gemini-cli/pull/21713) -- feat(core): Introduce `AgentLoopContext`. by @joshualitt in - [#21198](https://github.com/google-gemini/gemini-cli/pull/21198) -- fix(core): resolve symlinks for non-existent paths during validation by - @Adib234 in [#21487](https://github.com/google-gemini/gemini-cli/pull/21487) -- docs: document tool exclusion from memory via deny policy by @Abhijit-2592 in - [#21428](https://github.com/google-gemini/gemini-cli/pull/21428) -- perf(core): cache loadApiKey to reduce redundant keychain access by @sehoon38 - in [#21520](https://github.com/google-gemini/gemini-cli/pull/21520) -- feat(cli): implement /upgrade command by @sehoon38 in - [#21511](https://github.com/google-gemini/gemini-cli/pull/21511) -- Feat/browser agent progress emission by @kunal-10-cloud in - [#21218](https://github.com/google-gemini/gemini-cli/pull/21218) -- fix(settings): display objects as JSON instead of [object Object] by - @Zheyuan-Lin in - [#21458](https://github.com/google-gemini/gemini-cli/pull/21458) -- Unmarshall update by @DavidAPierce in - [#21721](https://github.com/google-gemini/gemini-cli/pull/21721) -- Update mcp's list function to check for disablement. by @DavidAPierce in - [#21148](https://github.com/google-gemini/gemini-cli/pull/21148) -- robustness(core): static checks to validate history is immutable by @jacob314 - in [#21228](https://github.com/google-gemini/gemini-cli/pull/21228) -- refactor(cli): better react patterns for BaseSettingsDialog by @psinha40898 in - [#21206](https://github.com/google-gemini/gemini-cli/pull/21206) -- feat(security): implement robust IP validation and safeFetch foundation by - @alisa-alisa in - [#21401](https://github.com/google-gemini/gemini-cli/pull/21401) -- feat(core): improve subagent result display by @joshualitt in - [#20378](https://github.com/google-gemini/gemini-cli/pull/20378) -- docs: fix broken markdown syntax and anchor links in /tools by @campox747 in - [#20902](https://github.com/google-gemini/gemini-cli/pull/20902) -- feat(policy): support subagent-specific policies in TOML by @akh64bit in - [#21431](https://github.com/google-gemini/gemini-cli/pull/21431) -- Add script to speed up reviewing PRs adding a worktree. by @jacob314 in - [#21748](https://github.com/google-gemini/gemini-cli/pull/21748) -- fix(core): prevent infinite recursion in symlink resolution by @Adib234 in - [#21750](https://github.com/google-gemini/gemini-cli/pull/21750) -- fix(docs): fix headless mode docs by @ame2en in - [#21287](https://github.com/google-gemini/gemini-cli/pull/21287) -- feat/redesign header compact by @jacob314 in - [#20922](https://github.com/google-gemini/gemini-cli/pull/20922) -- refactor: migrate to useKeyMatchers hook by @scidomino in - [#21753](https://github.com/google-gemini/gemini-cli/pull/21753) -- perf(cli): cache loadSettings to reduce redundant disk I/O at startup by - @sehoon38 in [#21521](https://github.com/google-gemini/gemini-cli/pull/21521) -- fix(core): resolve Windows line ending and path separation bugs across CLI by - @muhammadusman586 in - [#21068](https://github.com/google-gemini/gemini-cli/pull/21068) -- docs: fix heading formatting in commands.md and phrasing in tools-api.md by - @campox747 in [#20679](https://github.com/google-gemini/gemini-cli/pull/20679) -- refactor(ui): unify keybinding infrastructure and support string - initialization by @scidomino in - [#21776](https://github.com/google-gemini/gemini-cli/pull/21776) -- Add support for updating extension sources and names by @chrstnb in - [#21715](https://github.com/google-gemini/gemini-cli/pull/21715) -- fix(core): handle GUI editor non-zero exit codes gracefully by @reyyanxahmed - in [#20376](https://github.com/google-gemini/gemini-cli/pull/20376) -- fix(core): destroy PTY on kill() and exception to prevent fd leak by @nbardy - in [#21693](https://github.com/google-gemini/gemini-cli/pull/21693) -- fix(docs): update theme screenshots and add missing themes by @ashmod in - [#20689](https://github.com/google-gemini/gemini-cli/pull/20689) -- refactor(cli): rename 'return' key to 'enter' internally by @scidomino in - [#21796](https://github.com/google-gemini/gemini-cli/pull/21796) -- build(release): restrict npm bundling to non-stable tags by @sehoon38 in - [#21821](https://github.com/google-gemini/gemini-cli/pull/21821) -- fix(core): override toolRegistry property for sub-agent schedulers by - @gsquared94 in - [#21766](https://github.com/google-gemini/gemini-cli/pull/21766) -- fix(cli): make footer items equally spaced by @jacob314 in - [#21843](https://github.com/google-gemini/gemini-cli/pull/21843) -- docs: clarify global policy rules application in plan mode by @jerop in - [#21864](https://github.com/google-gemini/gemini-cli/pull/21864) -- fix(core): ensure correct flash model steering in plan mode implementation - phase by @jerop in - [#21871](https://github.com/google-gemini/gemini-cli/pull/21871) -- fix(core): update @a2a-js/sdk to 0.3.11 by @adamfweidman in - [#21875](https://github.com/google-gemini/gemini-cli/pull/21875) -- refactor(core): improve API response error logging when retry by @yunaseoul in - [#21784](https://github.com/google-gemini/gemini-cli/pull/21784) -- fix(ui): handle headless execution in credits and upgrade dialogs by - @gsquared94 in - [#21850](https://github.com/google-gemini/gemini-cli/pull/21850) -- fix(core): treat retryable errors with >5 min delay as terminal quota errors - by @gsquared94 in - [#21881](https://github.com/google-gemini/gemini-cli/pull/21881) -- feat(telemetry): add specific PR, issue, and custom tracking IDs for GitHub - Actions by @cocosheng-g in - [#21129](https://github.com/google-gemini/gemini-cli/pull/21129) -- feat(core): add OAuth2 Authorization Code auth provider for A2A agents by - @SandyTao520 in - [#21496](https://github.com/google-gemini/gemini-cli/pull/21496) -- feat(cli): give visibility to /tools list command in the TUI and follow the - subcommand pattern of other commands by @JayadityaGit in - [#21213](https://github.com/google-gemini/gemini-cli/pull/21213) -- Handle dirty worktrees better and warn about running scripts/review.sh on - untrusted code. by @jacob314 in - [#21791](https://github.com/google-gemini/gemini-cli/pull/21791) -- feat(policy): support auto-add to policy by default and scoped persistence by + [#21878](https://github.com/google-gemini/gemini-cli/pull/21878) +- fix: clean up /clear and /resume by @jackwotherspoon in + [#22007](https://github.com/google-gemini/gemini-cli/pull/22007) +- fix(core)#20941: reap orphaned descendant processes on PTY abort by @manavmax + in [#21124](https://github.com/google-gemini/gemini-cli/pull/21124) +- fix(core): update language detection to use LSP 3.18 identifiers by @yunaseoul + in [#21931](https://github.com/google-gemini/gemini-cli/pull/21931) +- feat(cli): support removing keybindings via '-' prefix by @scidomino in + [#22042](https://github.com/google-gemini/gemini-cli/pull/22042) +- feat(policy): add --admin-policy flag for supplemental admin policies by + @galz10 in [#20360](https://github.com/google-gemini/gemini-cli/pull/20360) +- merge duplicate imports packages/cli/src subtask1 by @Nixxx19 in + [#22040](https://github.com/google-gemini/gemini-cli/pull/22040) +- perf(core): parallelize user quota and experiments fetching in refreshAuth by + @sehoon38 in [#21648](https://github.com/google-gemini/gemini-cli/pull/21648) +- Changelog for v0.34.0-preview.0 by @gemini-cli-robot in + [#21965](https://github.com/google-gemini/gemini-cli/pull/21965) +- Changelog for v0.33.0 by @gemini-cli-robot in + [#21967](https://github.com/google-gemini/gemini-cli/pull/21967) +- fix(core): handle EISDIR in robustRealpath on Windows by @sehoon38 in + [#21984](https://github.com/google-gemini/gemini-cli/pull/21984) +- feat(core): include initiationMethod in conversation interaction telemetry by + @yunaseoul in [#22054](https://github.com/google-gemini/gemini-cli/pull/22054) +- feat(ui): add vim yank/paste (y/p/P) with unnamed register by @aanari in + [#22026](https://github.com/google-gemini/gemini-cli/pull/22026) +- fix(core): enable numerical routing for api key users by @sehoon38 in + [#21977](https://github.com/google-gemini/gemini-cli/pull/21977) +- feat(telemetry): implement retry attempt telemetry for network related retries + by @aishaneeshah in + [#22027](https://github.com/google-gemini/gemini-cli/pull/22027) +- fix(policy): remove unnecessary escapeRegex from pattern builders by @spencer426 in - [#20361](https://github.com/google-gemini/gemini-cli/pull/20361) -- fix(core): handle AbortError when ESC cancels tool execution by @PrasannaPal21 - in [#20863](https://github.com/google-gemini/gemini-cli/pull/20863) -- fix(release): Improve Patch Release Workflow Comments: Clearer Approval - Guidance by @jerop in - [#21894](https://github.com/google-gemini/gemini-cli/pull/21894) -- docs: clarify telemetry setup and comprehensive data map by @jerop in - [#21879](https://github.com/google-gemini/gemini-cli/pull/21879) -- feat(core): add per-model token usage to stream-json output by @yongruilin in - [#21839](https://github.com/google-gemini/gemini-cli/pull/21839) -- docs: remove experimental badge from plan mode in sidebar by @jerop in - [#21906](https://github.com/google-gemini/gemini-cli/pull/21906) -- fix(cli): prevent race condition in loop detection retry by @skyvanguard in - [#17916](https://github.com/google-gemini/gemini-cli/pull/17916) -- Add behavioral evals for tracker by @anj-s in - [#20069](https://github.com/google-gemini/gemini-cli/pull/20069) -- fix(auth): update terminology to 'sign in' and 'sign out' by @clocky in - [#20892](https://github.com/google-gemini/gemini-cli/pull/20892) -- docs(mcp): standardize mcp tool fqn documentation by @abhipatel12 in - [#21664](https://github.com/google-gemini/gemini-cli/pull/21664) -- fix(ui): prevent empty tool-group border stubs after filtering by @Aaxhirrr in - [#21852](https://github.com/google-gemini/gemini-cli/pull/21852) -- make command names consistent by @scidomino in - [#21907](https://github.com/google-gemini/gemini-cli/pull/21907) -- refactor: remove agent_card_requires_auth config flag by @adamfweidman in - [#21914](https://github.com/google-gemini/gemini-cli/pull/21914) -- feat(a2a): implement standardized normalization and streaming reassembly by - @alisa-alisa in - [#21402](https://github.com/google-gemini/gemini-cli/pull/21402) -- feat(cli): enable skill activation via slash commands by @NTaylorMullen in - [#21758](https://github.com/google-gemini/gemini-cli/pull/21758) -- docs(cli): mention per-model token usage in stream-json result event by - @yongruilin in - [#21908](https://github.com/google-gemini/gemini-cli/pull/21908) -- fix(plan): prevent plan truncation in approval dialog by supporting - unconstrained heights by @Adib234 in - [#21037](https://github.com/google-gemini/gemini-cli/pull/21037) -- feat(a2a): switch from callback-based to event-driven tool scheduler by - @cocosheng-g in - [#21467](https://github.com/google-gemini/gemini-cli/pull/21467) -- feat(voice): implement speech-friendly response formatter by @ayush31010 in - [#20989](https://github.com/google-gemini/gemini-cli/pull/20989) -- feat: add pulsating blue border automation overlay to browser agent by - @kunal-10-cloud in - [#21173](https://github.com/google-gemini/gemini-cli/pull/21173) -- Add extensionRegistryURI setting to change where the registry is read from by - @kevinjwang1 in - [#20463](https://github.com/google-gemini/gemini-cli/pull/20463) -- fix: patch gaxios v7 Array.toString() stream corruption by @gsquared94 in - [#21884](https://github.com/google-gemini/gemini-cli/pull/21884) -- fix: prevent hangs in non-interactive mode and improve agent guidance by - @cocosheng-g in - [#20893](https://github.com/google-gemini/gemini-cli/pull/20893) -- Add ExtensionDetails dialog and support install by @chrstnb in - [#20845](https://github.com/google-gemini/gemini-cli/pull/20845) -- chore/release: bump version to 0.34.0-nightly.20260310.4653b126f by - @gemini-cli-robot in - [#21816](https://github.com/google-gemini/gemini-cli/pull/21816) -- Changelog for v0.33.0-preview.13 by @gemini-cli-robot in - [#21927](https://github.com/google-gemini/gemini-cli/pull/21927) -- fix(cli): stabilize prompt layout to prevent jumping when typing by + [#21921](https://github.com/google-gemini/gemini-cli/pull/21921) +- fix(core): preserve dynamic tool descriptions on session resume by @sehoon38 + in [#18835](https://github.com/google-gemini/gemini-cli/pull/18835) +- chore: allow 'gemini-3.1' in sensitive keyword linter by @scidomino in + [#22065](https://github.com/google-gemini/gemini-cli/pull/22065) +- feat(core): support custom base URL via env vars by @junaiddshaukat in + [#21561](https://github.com/google-gemini/gemini-cli/pull/21561) +- merge duplicate imports packages/cli/src subtask2 by @Nixxx19 in + [#22051](https://github.com/google-gemini/gemini-cli/pull/22051) +- fix(core): silently retry API errors up to 3 times before halting session by + @spencer426 in + [#21989](https://github.com/google-gemini/gemini-cli/pull/21989) +- feat(core): simplify subagent success UI and improve early termination display + by @abhipatel12 in + [#21917](https://github.com/google-gemini/gemini-cli/pull/21917) +- merge duplicate imports packages/cli/src subtask3 by @Nixxx19 in + [#22056](https://github.com/google-gemini/gemini-cli/pull/22056) +- fix(hooks): fix BeforeAgent/AfterAgent inconsistencies (#18514) by @krishdef7 + in [#21383](https://github.com/google-gemini/gemini-cli/pull/21383) +- feat(core): implement SandboxManager interface and config schema by @galz10 in + [#21774](https://github.com/google-gemini/gemini-cli/pull/21774) +- docs: document npm deprecation warnings as safe to ignore by @h30s in + [#20692](https://github.com/google-gemini/gemini-cli/pull/20692) +- fix: remove status/need-triage from maintainer-only issues by @SandyTao520 in + [#22044](https://github.com/google-gemini/gemini-cli/pull/22044) +- fix(core): propagate subagent context to policy engine by @NTaylorMullen in + [#22086](https://github.com/google-gemini/gemini-cli/pull/22086) +- fix(cli): resolve skill uninstall failure when skill name is updated by @NTaylorMullen in - [#21081](https://github.com/google-gemini/gemini-cli/pull/21081) -- fix: preserve prompt text when cancelling streaming by @Nixxx19 in - [#21103](https://github.com/google-gemini/gemini-cli/pull/21103) -- fix: robust UX for remote agent errors by @Shyam-Raghuwanshi in - [#20307](https://github.com/google-gemini/gemini-cli/pull/20307) -- feat: implement background process logging and cleanup by @galz10 in - [#21189](https://github.com/google-gemini/gemini-cli/pull/21189) -- Changelog for v0.33.0-preview.14 by @gemini-cli-robot in - [#21938](https://github.com/google-gemini/gemini-cli/pull/21938) -- fix(patch): cherry-pick 45faf4d to release/v0.34.0-preview.0-pr-22148 + [#22085](https://github.com/google-gemini/gemini-cli/pull/22085) +- docs(plan): clarify interactive plan editing with Ctrl+X by @Adib234 in + [#22076](https://github.com/google-gemini/gemini-cli/pull/22076) +- fix(policy): ensure user policies are loaded when policyPaths is empty by + @NTaylorMullen in + [#22090](https://github.com/google-gemini/gemini-cli/pull/22090) +- Docs: Add documentation for model steering (experimental). by @jkcinouye in + [#21154](https://github.com/google-gemini/gemini-cli/pull/21154) +- Add issue for automated changelogs by @g-samroberts in + [#21912](https://github.com/google-gemini/gemini-cli/pull/21912) +- fix(core): secure argsPattern and revert WEB_FETCH_TOOL_NAME escalation by + @spencer426 in + [#22104](https://github.com/google-gemini/gemini-cli/pull/22104) +- feat(core): differentiate User-Agent for a2a-server and ACP clients by + @bdmorgan in [#22059](https://github.com/google-gemini/gemini-cli/pull/22059) +- refactor(core): extract ExecutionLifecycleService for tool backgrounding by + @adamfweidman in + [#21717](https://github.com/google-gemini/gemini-cli/pull/21717) +- feat: Display pending and confirming tool calls by @sripasg in + [#22106](https://github.com/google-gemini/gemini-cli/pull/22106) +- feat(browser): implement input blocker overlay during automation by + @kunal-10-cloud in + [#21132](https://github.com/google-gemini/gemini-cli/pull/21132) +- fix: register themes on extension load not start by @jackwotherspoon in + [#22148](https://github.com/google-gemini/gemini-cli/pull/22148) +- feat(ui): Do not show Ultra users /upgrade hint (#22154) by @sehoon38 in + [#22156](https://github.com/google-gemini/gemini-cli/pull/22156) +- chore: remove unnecessary log for themes by @jackwotherspoon in + [#22165](https://github.com/google-gemini/gemini-cli/pull/22165) +- fix(core): resolve MCP tool FQN validation, schema export, and wildcards in + subagents by @abhipatel12 in + [#22069](https://github.com/google-gemini/gemini-cli/pull/22069) +- fix(cli): validate --model argument at startup by @JaisalJain in + [#21393](https://github.com/google-gemini/gemini-cli/pull/21393) +- fix(core): handle policy ALLOW for exit_plan_mode by @backnotprop in + [#21802](https://github.com/google-gemini/gemini-cli/pull/21802) +- feat(telemetry): add Clearcut instrumentation for AI credits billing events by + @gsquared94 in + [#22153](https://github.com/google-gemini/gemini-cli/pull/22153) +- feat(core): add google credentials provider for remote agents by @adamfweidman + in [#21024](https://github.com/google-gemini/gemini-cli/pull/21024) +- test(cli): add integration test for node deprecation warnings by @Nixxx19 in + [#20215](https://github.com/google-gemini/gemini-cli/pull/20215) +- feat(cli): allow safe tools to execute concurrently while agent is busy by + @spencer426 in + [#21988](https://github.com/google-gemini/gemini-cli/pull/21988) +- feat(core): implement model-driven parallel tool scheduler by @abhipatel12 in + [#21933](https://github.com/google-gemini/gemini-cli/pull/21933) +- update vulnerable deps by @scidomino in + [#22180](https://github.com/google-gemini/gemini-cli/pull/22180) +- fix(core): fix startup stats to use int values for timestamps and durations by + @yunaseoul in [#22201](https://github.com/google-gemini/gemini-cli/pull/22201) +- fix(core): prevent duplicate tool schemas for instantiated tools by + @abhipatel12 in + [#22204](https://github.com/google-gemini/gemini-cli/pull/22204) +- fix(core): add proxy routing support for remote A2A subagents by @adamfweidman + in [#22199](https://github.com/google-gemini/gemini-cli/pull/22199) +- fix(core/ide): add Antigravity CLI fallbacks by @apfine in + [#22030](https://github.com/google-gemini/gemini-cli/pull/22030) +- fix(browser): fix duplicate function declaration error in browser agent by + @gsquared94 in + [#22207](https://github.com/google-gemini/gemini-cli/pull/22207) +- feat(core): implement Stage 1 improvements for webfetch tool by @aishaneeshah + in [#21313](https://github.com/google-gemini/gemini-cli/pull/21313) +- Changelog for v0.34.0-preview.1 by @gemini-cli-robot in + [#22194](https://github.com/google-gemini/gemini-cli/pull/22194) +- perf(cli): enable code splitting and deferred UI loading by @sehoon38 in + [#22117](https://github.com/google-gemini/gemini-cli/pull/22117) +- fix: remove unused img.png from project root by @SandyTao520 in + [#22222](https://github.com/google-gemini/gemini-cli/pull/22222) +- docs(local model routing): add docs on how to use Gemma for local model + routing by @douglas-reid in + [#21365](https://github.com/google-gemini/gemini-cli/pull/21365) +- feat(a2a): enable native gRPC support and protocol routing by @alisa-alisa in + [#21403](https://github.com/google-gemini/gemini-cli/pull/21403) +- fix(cli): escape @ symbols on paste to prevent unintended file expansion by + @krishdef7 in [#21239](https://github.com/google-gemini/gemini-cli/pull/21239) +- feat(core): add trajectoryId to ConversationOffered telemetry by @yunaseoul in + [#22214](https://github.com/google-gemini/gemini-cli/pull/22214) +- docs: clarify that tools.core is an allowlist for ALL built-in tools by + @hobostay in [#18813](https://github.com/google-gemini/gemini-cli/pull/18813) +- docs(plan): document hooks with plan mode by @ruomengz in + [#22197](https://github.com/google-gemini/gemini-cli/pull/22197) +- Changelog for v0.33.1 by @gemini-cli-robot in + [#22235](https://github.com/google-gemini/gemini-cli/pull/22235) +- build(ci): fix false positive evals trigger on merge commits by @gundermanc in + [#22237](https://github.com/google-gemini/gemini-cli/pull/22237) +- fix(core): explicitly pass messageBus to policy engine for MCP tool saves by + @abhipatel12 in + [#22255](https://github.com/google-gemini/gemini-cli/pull/22255) +- feat(core): Fully migrate packages/core to AgentLoopContext. by @joshualitt in + [#22115](https://github.com/google-gemini/gemini-cli/pull/22115) +- feat(core): increase sub-agent turn and time limits by @bdmorgan in + [#22196](https://github.com/google-gemini/gemini-cli/pull/22196) +- feat(core): instrument file system tools for JIT context discovery by + @SandyTao520 in + [#22082](https://github.com/google-gemini/gemini-cli/pull/22082) +- refactor(ui): extract pure session browser utilities by @abhipatel12 in + [#22256](https://github.com/google-gemini/gemini-cli/pull/22256) +- fix(plan): Fix AskUser evals by @Adib234 in + [#22074](https://github.com/google-gemini/gemini-cli/pull/22074) +- fix(settings): prevent j/k navigation keys from intercepting edit buffer input + by @student-ankitpandit in + [#21865](https://github.com/google-gemini/gemini-cli/pull/21865) +- feat(skills): improve async-pr-review workflow and logging by @mattKorwel in + [#21790](https://github.com/google-gemini/gemini-cli/pull/21790) +- refactor(cli): consolidate getErrorMessage utility to core by @scidomino in + [#22190](https://github.com/google-gemini/gemini-cli/pull/22190) +- fix(core): show descriptive error messages when saving settings fails by + @afarber in [#18095](https://github.com/google-gemini/gemini-cli/pull/18095) +- docs(core): add authentication guide for remote subagents by @adamfweidman in + [#22178](https://github.com/google-gemini/gemini-cli/pull/22178) +- docs: overhaul subagents documentation and add /agents command by @abhipatel12 + in [#22345](https://github.com/google-gemini/gemini-cli/pull/22345) +- refactor(ui): extract SessionBrowser static ui components by @abhipatel12 in + [#22348](https://github.com/google-gemini/gemini-cli/pull/22348) +- test: add Object.create context regression test and tool confirmation + integration test by @gsquared94 in + [#22356](https://github.com/google-gemini/gemini-cli/pull/22356) +- feat(tracker): return TodoList display for tracker tools by @anj-s in + [#22060](https://github.com/google-gemini/gemini-cli/pull/22060) +- feat(agent): add allowed domain restrictions for browser agent by + @cynthialong0-0 in + [#21775](https://github.com/google-gemini/gemini-cli/pull/21775) +- chore/release: bump version to 0.35.0-nightly.20260313.bb060d7a9 by + @gemini-cli-robot in + [#22251](https://github.com/google-gemini/gemini-cli/pull/22251) +- Move keychain fallback to keychain service by @chrstnb in + [#22332](https://github.com/google-gemini/gemini-cli/pull/22332) +- feat(core): integrate SandboxManager to sandbox all process-spawning tools by + @galz10 in [#22231](https://github.com/google-gemini/gemini-cli/pull/22231) +- fix(cli): support CJK input and full Unicode scalar values in terminal + protocols by @scidomino in + [#22353](https://github.com/google-gemini/gemini-cli/pull/22353) +- Promote stable tests. by @gundermanc in + [#22253](https://github.com/google-gemini/gemini-cli/pull/22253) +- feat(tracker): add tracker policy by @anj-s in + [#22379](https://github.com/google-gemini/gemini-cli/pull/22379) +- feat(security): add disableAlwaysAllow setting to disable auto-approvals by + @galz10 in [#21941](https://github.com/google-gemini/gemini-cli/pull/21941) +- Revert "fix(cli): validate --model argument at startup" by @sehoon38 in + [#22378](https://github.com/google-gemini/gemini-cli/pull/22378) +- fix(mcp): handle equivalent root resource URLs in OAuth validation by @galz10 + in [#20231](https://github.com/google-gemini/gemini-cli/pull/20231) +- fix(core): use session-specific temp directory for task tracker by @anj-s in + [#22382](https://github.com/google-gemini/gemini-cli/pull/22382) +- Fix issue where config was undefined. by @gundermanc in + [#22397](https://github.com/google-gemini/gemini-cli/pull/22397) +- fix(core): deduplicate project memory when JIT context is enabled by + @SandyTao520 in + [#22234](https://github.com/google-gemini/gemini-cli/pull/22234) +- feat(prompts): implement Topic-Action-Summary model for verbosity reduction by + @Abhijit-2592 in + [#21503](https://github.com/google-gemini/gemini-cli/pull/21503) +- fix(core): fix manual deletion of subagent histories by @abhipatel12 in + [#22407](https://github.com/google-gemini/gemini-cli/pull/22407) +- Add registry var by @kevinjwang1 in + [#22224](https://github.com/google-gemini/gemini-cli/pull/22224) +- Add ModelDefinitions to ModelConfigService by @kevinjwang1 in + [#22302](https://github.com/google-gemini/gemini-cli/pull/22302) +- fix(cli): improve command conflict handling for skills by @NTaylorMullen in + [#21942](https://github.com/google-gemini/gemini-cli/pull/21942) +- fix(core): merge user settings with extension-provided MCP servers by + @abhipatel12 in + [#22484](https://github.com/google-gemini/gemini-cli/pull/22484) +- fix(core): skip discovery for incomplete MCP configs and resolve merge race + condition by @abhipatel12 in + [#22494](https://github.com/google-gemini/gemini-cli/pull/22494) +- fix(automation): harden stale PR closer permissions and maintainer detection + by @bdmorgan in + [#22558](https://github.com/google-gemini/gemini-cli/pull/22558) +- fix(automation): evaluate staleness before checking protected labels by + @bdmorgan in [#22561](https://github.com/google-gemini/gemini-cli/pull/22561) +- feat(agent): replace the runtime npx for browser agent chrome devtool mcp with + pre-built bundle by @cynthialong0-0 in + [#22213](https://github.com/google-gemini/gemini-cli/pull/22213) +- perf: optimize TrackerService dependency checks by @anj-s in + [#22384](https://github.com/google-gemini/gemini-cli/pull/22384) +- docs(policy): remove trailing space from commandPrefix examples by @kawasin73 + in [#22264](https://github.com/google-gemini/gemini-cli/pull/22264) +- fix(a2a-server): resolve unsafe assignment lint errors by @ehedlund in + [#22661](https://github.com/google-gemini/gemini-cli/pull/22661) +- fix: Adjust ToolGroupMessage filtering to hide Confirming and show Canceled + tool calls. by @sripasg in + [#22230](https://github.com/google-gemini/gemini-cli/pull/22230) +- Disallow Object.create() and reflect. by @gundermanc in + [#22408](https://github.com/google-gemini/gemini-cli/pull/22408) +- Guard pro model usage by @sehoon38 in + [#22665](https://github.com/google-gemini/gemini-cli/pull/22665) +- refactor(core): Creates AgentSession abstraction for consolidated agent + interface. by @mbleigh in + [#22270](https://github.com/google-gemini/gemini-cli/pull/22270) +- docs(changelog): remove internal commands from release notes by + @jackwotherspoon in + [#22529](https://github.com/google-gemini/gemini-cli/pull/22529) +- feat: enable subagents by @abhipatel12 in + [#22386](https://github.com/google-gemini/gemini-cli/pull/22386) +- feat(extensions): implement cryptographic integrity verification for extension + updates by @ehedlund in + [#21772](https://github.com/google-gemini/gemini-cli/pull/21772) +- feat(tracker): polish UI sorting and formatting by @anj-s in + [#22437](https://github.com/google-gemini/gemini-cli/pull/22437) +- Changelog for v0.34.0-preview.2 by @gemini-cli-robot in + [#22220](https://github.com/google-gemini/gemini-cli/pull/22220) +- fix(core): fix three JIT context bugs in read_file, read_many_files, and + memoryDiscovery by @SandyTao520 in + [#22679](https://github.com/google-gemini/gemini-cli/pull/22679) +- refactor(core): introduce InjectionService with source-aware injection and + backend-native background completions by @adamfweidman in + [#22544](https://github.com/google-gemini/gemini-cli/pull/22544) +- Linux sandbox bubblewrap by @DavidAPierce in + [#22680](https://github.com/google-gemini/gemini-cli/pull/22680) +- feat(core): increase thought signature retry resilience by @bdmorgan in + [#22202](https://github.com/google-gemini/gemini-cli/pull/22202) +- feat(core): implement Stage 2 security and consistency improvements for + web_fetch by @aishaneeshah in + [#22217](https://github.com/google-gemini/gemini-cli/pull/22217) +- refactor(core): replace positional execute params with ExecuteOptions bag by + @adamfweidman in + [#22674](https://github.com/google-gemini/gemini-cli/pull/22674) +- feat(config): enable JIT context loading by default by @SandyTao520 in + [#22736](https://github.com/google-gemini/gemini-cli/pull/22736) +- fix(config): ensure discoveryMaxDirs is passed to global config during + initialization by @kevin-ramdass in + [#22744](https://github.com/google-gemini/gemini-cli/pull/22744) +- fix(plan): allowlist get_internal_docs in Plan Mode by @Adib234 in + [#22668](https://github.com/google-gemini/gemini-cli/pull/22668) +- Changelog for v0.34.0-preview.3 by @gemini-cli-robot in + [#22393](https://github.com/google-gemini/gemini-cli/pull/22393) +- feat(core): add foundation for subagent tool isolation by @akh64bit in + [#22708](https://github.com/google-gemini/gemini-cli/pull/22708) +- fix(core): handle surrogate pairs in truncateString by @sehoon38 in + [#22754](https://github.com/google-gemini/gemini-cli/pull/22754) +- fix(cli): override j/k navigation in settings dialog to fix search input + conflict by @sehoon38 in + [#22800](https://github.com/google-gemini/gemini-cli/pull/22800) +- feat(plan): add 'All the above' option to multi-select AskUser questions by + @Adib234 in [#22365](https://github.com/google-gemini/gemini-cli/pull/22365) +- docs: distribute package-specific GEMINI.md context to each package by + @SandyTao520 in + [#22734](https://github.com/google-gemini/gemini-cli/pull/22734) +- fix(cli): clean up stale pasted placeholder metadata after word/line deletions + by @Jomak-x in + [#20375](https://github.com/google-gemini/gemini-cli/pull/20375) +- refactor(core): align JIT memory placement with tiered context model by + @SandyTao520 in + [#22766](https://github.com/google-gemini/gemini-cli/pull/22766) +- Linux sandbox seccomp by @DavidAPierce in + [#22815](https://github.com/google-gemini/gemini-cli/pull/22815) +- fix(patch): cherry-pick 4e5dfd0 to release/v0.35.0-preview.1-pr-23074 to patch + version v0.35.0-preview.1 and create version 0.35.0-preview.2 by + @gemini-cli-robot in + [#23134](https://github.com/google-gemini/gemini-cli/pull/23134) +- fix(patch): cherry-pick daf3691 to release/v0.35.0-preview.2-pr-23558 to patch + version v0.35.0-preview.2 and create version 0.35.0-preview.3 by + @gemini-cli-robot in + [#23565](https://github.com/google-gemini/gemini-cli/pull/23565) +- fix(patch): cherry-pick b2d6dc4 to release/v0.35.0-preview.4-pr-23546 [CONFLICTS] by @gemini-cli-robot in - [#22174](https://github.com/google-gemini/gemini-cli/pull/22174) -- fix(patch): cherry-pick 8432bce to release/v0.34.0-preview.1-pr-22069 to patch - version v0.34.0-preview.1 and create version 0.34.0-preview.2 by - @gemini-cli-robot in - [#22205](https://github.com/google-gemini/gemini-cli/pull/22205) -- fix(patch): cherry-pick 24adacd to release/v0.34.0-preview.2-pr-22332 to patch - version v0.34.0-preview.2 and create version 0.34.0-preview.3 by - @gemini-cli-robot in - [#22391](https://github.com/google-gemini/gemini-cli/pull/22391) -- fix(patch): cherry-pick 48130eb to release/v0.34.0-preview.3-pr-22665 to patch - version v0.34.0-preview.3 and create version 0.34.0-preview.4 by - @gemini-cli-robot in - [#22719](https://github.com/google-gemini/gemini-cli/pull/22719) + [#23585](https://github.com/google-gemini/gemini-cli/pull/23585) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.33.2...v0.34.0 +https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.0 From 2e03e3aed5c56db1b9fda3a751402c48207bcbe6 Mon Sep 17 00:00:00 2001 From: Alisa <62909685+alisa-alisa@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:48:45 -0700 Subject: [PATCH 30/49] feat(evals): add reliability harvester and 500/503 retry support (#23626) --- .github/workflows/chained_e2e.yml | 12 ++ .github/workflows/evals-nightly.yml | 2 + evals/test-helper.test.ts | 207 ++++++++++++++++++++++++ evals/test-helper.ts | 242 ++++++++++++++++++++-------- evals/vitest.config.ts | 4 - scripts/harvest_api_reliability.sh | 117 ++++++++++++++ 6 files changed, 509 insertions(+), 75 deletions(-) create mode 100644 evals/test-helper.test.ts create mode 100755 scripts/harvest_api_reliability.sh diff --git a/.github/workflows/chained_e2e.yml b/.github/workflows/chained_e2e.yml index 8d714b34b0..fe87fb1d5d 100644 --- a/.github/workflows/chained_e2e.yml +++ b/.github/workflows/chained_e2e.yml @@ -334,8 +334,20 @@ jobs: if: "${{ steps.check_evals.outputs.should_run == 'true' }}" env: GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' + GEMINI_MODEL: 'gemini-3-pro-preview' + # Disable Vitest internal retries to avoid double-retrying; + # custom retry logic is handled in evals/test-helper.ts + VITEST_RETRY: 0 run: 'npm run test:always_passing_evals' + - name: 'Upload Reliability Logs' + if: "always() && steps.check_evals.outputs.should_run == 'true'" + uses: 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02' # ratchet:actions/upload-artifact@v4 + with: + name: 'eval-logs-${{ github.run_id }}-${{ github.run_attempt }}' + path: 'evals/logs/api-reliability.jsonl' + retention-days: 7 + e2e: name: 'E2E' if: | diff --git a/.github/workflows/evals-nightly.yml b/.github/workflows/evals-nightly.yml index ee17a95121..9acc1de050 100644 --- a/.github/workflows/evals-nightly.yml +++ b/.github/workflows/evals-nightly.yml @@ -61,6 +61,8 @@ jobs: GEMINI_MODEL: '${{ matrix.model }}' RUN_EVALS: "${{ github.event.inputs.run_all != 'false' }}" TEST_NAME_PATTERN: '${{ github.event.inputs.test_name_pattern }}' + # Disable Vitest internal retries to avoid double-retrying; + # custom retry logic is handled in evals/test-helper.ts VITEST_RETRY: 0 run: | CMD="npm run test:all_evals" diff --git a/evals/test-helper.test.ts b/evals/test-helper.test.ts new file mode 100644 index 0000000000..c0147cda75 --- /dev/null +++ b/evals/test-helper.test.ts @@ -0,0 +1,207 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { internalEvalTest } from './test-helper.js'; +import { TestRig } from '@google/gemini-cli-test-utils'; + +// Mock TestRig to control API success/failure +vi.mock('@google/gemini-cli-test-utils', () => { + return { + TestRig: vi.fn().mockImplementation(() => ({ + setup: vi.fn(), + run: vi.fn(), + cleanup: vi.fn(), + readToolLogs: vi.fn().mockReturnValue([]), + _lastRunStderr: '', + })), + }; +}); + +describe('evalTest reliability logic', () => { + const LOG_DIR = path.resolve(process.cwd(), 'evals/logs'); + const RELIABILITY_LOG = path.join(LOG_DIR, 'api-reliability.jsonl'); + + beforeEach(() => { + vi.clearAllMocks(); + if (fs.existsSync(RELIABILITY_LOG)) { + fs.unlinkSync(RELIABILITY_LOG); + } + }); + + afterEach(() => { + if (fs.existsSync(RELIABILITY_LOG)) { + fs.unlinkSync(RELIABILITY_LOG); + } + }); + + it('should retry 3 times on 500 INTERNAL error and then SKIP', async () => { + const mockRig = new TestRig() as any; + (TestRig as any).mockReturnValue(mockRig); + + // Simulate permanent 500 error + mockRig.run.mockRejectedValue(new Error('status: INTERNAL - API Down')); + + // Execute the test function directly + await internalEvalTest({ + name: 'test-api-failure', + prompt: 'do something', + assert: async () => {}, + }); + + // Verify retries: 1 initial + 3 retries = 4 setups/runs + expect(mockRig.run).toHaveBeenCalledTimes(4); + + // Verify log content + const logContent = fs + .readFileSync(RELIABILITY_LOG, 'utf-8') + .trim() + .split('\n'); + expect(logContent.length).toBe(4); + + const entries = logContent.map((line) => JSON.parse(line)); + expect(entries[0].status).toBe('RETRY'); + expect(entries[0].attempt).toBe(0); + expect(entries[3].status).toBe('SKIP'); + expect(entries[3].attempt).toBe(3); + expect(entries[3].testName).toBe('test-api-failure'); + }); + + it('should fail immediately on non-500 errors (like assertion failures)', async () => { + const mockRig = new TestRig() as any; + (TestRig as any).mockReturnValue(mockRig); + + // Simulate a real logic error/bug + mockRig.run.mockResolvedValue('Success'); + const assertError = new Error('Assertion failed: expected foo to be bar'); + + // Expect the test function to throw immediately + await expect( + internalEvalTest({ + name: 'test-logic-failure', + prompt: 'do something', + assert: async () => { + throw assertError; + }, + }), + ).rejects.toThrow('Assertion failed'); + + // Verify NO retries: only 1 attempt + expect(mockRig.run).toHaveBeenCalledTimes(1); + + // Verify NO reliability log was created (it's not an API error) + expect(fs.existsSync(RELIABILITY_LOG)).toBe(false); + }); + + it('should recover if a retry succeeds', async () => { + const mockRig = new TestRig() as any; + (TestRig as any).mockReturnValue(mockRig); + + // Fail once, then succeed + mockRig.run + .mockRejectedValueOnce(new Error('status: INTERNAL')) + .mockResolvedValueOnce('Success'); + + await internalEvalTest({ + name: 'test-recovery', + prompt: 'do something', + assert: async () => {}, + }); + + // Ran twice: initial (fail) + retry 1 (success) + expect(mockRig.run).toHaveBeenCalledTimes(2); + + // Log should only have the one RETRY entry + const logContent = fs + .readFileSync(RELIABILITY_LOG, 'utf-8') + .trim() + .split('\n'); + expect(logContent.length).toBe(1); + expect(JSON.parse(logContent[0]).status).toBe('RETRY'); + }); + + it('should retry 3 times on 503 UNAVAILABLE error and then SKIP', async () => { + const mockRig = new TestRig() as any; + (TestRig as any).mockReturnValue(mockRig); + + // Simulate permanent 503 error + mockRig.run.mockRejectedValue( + new Error('status: UNAVAILABLE - Service Busy'), + ); + + await internalEvalTest({ + name: 'test-api-503', + prompt: 'do something', + assert: async () => {}, + }); + + expect(mockRig.run).toHaveBeenCalledTimes(4); + + const logContent = fs + .readFileSync(RELIABILITY_LOG, 'utf-8') + .trim() + .split('\n'); + const entries = logContent.map((line) => JSON.parse(line)); + expect(entries[0].errorCode).toBe('503'); + expect(entries[3].status).toBe('SKIP'); + }); + + it('should throw if an absolute path is used in files', async () => { + const mockRig = new TestRig() as any; + (TestRig as any).mockReturnValue(mockRig); + mockRig.testDir = path.resolve(process.cwd(), 'test-dir-tmp'); + if (!fs.existsSync(mockRig.testDir)) { + fs.mkdirSync(mockRig.testDir, { recursive: true }); + } + + try { + await expect( + internalEvalTest({ + name: 'test-absolute-path', + prompt: 'do something', + files: { + '/etc/passwd': 'hacked', + }, + assert: async () => {}, + }), + ).rejects.toThrow('Invalid file path in test case: /etc/passwd'); + } finally { + if (fs.existsSync(mockRig.testDir)) { + fs.rmSync(mockRig.testDir, { recursive: true, force: true }); + } + } + }); + + it('should throw if directory traversal is detected in files', async () => { + const mockRig = new TestRig() as any; + (TestRig as any).mockReturnValue(mockRig); + mockRig.testDir = path.resolve(process.cwd(), 'test-dir-tmp'); + + // Create a mock test-dir + if (!fs.existsSync(mockRig.testDir)) { + fs.mkdirSync(mockRig.testDir, { recursive: true }); + } + + try { + await expect( + internalEvalTest({ + name: 'test-traversal', + prompt: 'do something', + files: { + '../sensitive.txt': 'hacked', + }, + assert: async () => {}, + }), + ).rejects.toThrow('Invalid file path in test case: ../sensitive.txt'); + } finally { + if (fs.existsSync(mockRig.testDir)) { + fs.rmSync(mockRig.testDir, { recursive: true, force: true }); + } + } + }); +}); diff --git a/evals/test-helper.ts b/evals/test-helper.ts index 7683fc510e..f79a78779a 100644 --- a/evals/test-helper.ts +++ b/evals/test-helper.ts @@ -39,87 +39,34 @@ export * from '@google/gemini-cli-test-utils'; export type EvalPolicy = 'ALWAYS_PASSES' | 'USUALLY_PASSES'; export function evalTest(policy: EvalPolicy, evalCase: EvalCase) { - const fn = async () => { + runEval( + policy, + evalCase.name, + () => internalEvalTest(evalCase), + evalCase.timeout, + ); +} + +export async function internalEvalTest(evalCase: EvalCase) { + const maxRetries = 3; + let attempt = 0; + + while (attempt <= maxRetries) { const rig = new TestRig(); const { logDir, sanitizedName } = await prepareLogDir(evalCase.name); const activityLogFile = path.join(logDir, `${sanitizedName}.jsonl`); const logFile = path.join(logDir, `${sanitizedName}.log`); let isSuccess = false; + try { rig.setup(evalCase.name, evalCase.params); - // Symlink node modules to reduce the amount of time needed to - // bootstrap test projects. - symlinkNodeModules(rig.testDir || ''); - if (evalCase.files) { - const acknowledgedAgents: Record> = {}; - const projectRoot = fs.realpathSync(rig.testDir!); - - for (const [filePath, content] of Object.entries(evalCase.files)) { - const fullPath = path.join(rig.testDir!, filePath); - fs.mkdirSync(path.dirname(fullPath), { recursive: true }); - fs.writeFileSync(fullPath, content); - - // If it's an agent file, calculate hash for acknowledgement - if ( - filePath.startsWith('.gemini/agents/') && - filePath.endsWith('.md') - ) { - const hash = crypto - .createHash('sha256') - .update(content) - .digest('hex'); - - try { - const agentDefs = await parseAgentMarkdown(fullPath, content); - if (agentDefs.length > 0) { - const agentName = agentDefs[0].name; - if (!acknowledgedAgents[projectRoot]) { - acknowledgedAgents[projectRoot] = {}; - } - acknowledgedAgents[projectRoot][agentName] = hash; - } - } catch (error) { - console.warn( - `Failed to parse agent for test acknowledgement: ${filePath}`, - error, - ); - } - } - } - - // Write acknowledged_agents.json to the home directory - if (Object.keys(acknowledgedAgents).length > 0) { - const ackPath = path.join( - rig.homeDir!, - '.gemini', - 'acknowledgments', - 'agents.json', - ); - fs.mkdirSync(path.dirname(ackPath), { recursive: true }); - fs.writeFileSync( - ackPath, - JSON.stringify(acknowledgedAgents, null, 2), - ); - } - - const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const }; - execSync('git init', execOptions); - execSync('git config user.email "test@example.com"', execOptions); - execSync('git config user.name "Test User"', execOptions); - - // Temporarily disable the interactive editor and git pager - // to avoid hanging the tests. It seems the the agent isn't - // consistently honoring the instructions to avoid interactive - // commands. - execSync('git config core.editor "true"', execOptions); - execSync('git config core.pager "cat"', execOptions); - execSync('git config commit.gpgsign false', execOptions); - execSync('git add .', execOptions); - execSync('git commit --allow-empty -m "Initial commit"', execOptions); + await setupTestFiles(rig, evalCase.files); } + symlinkNodeModules(rig.testDir || ''); + // If messages are provided, write a session file so --resume can load it. let sessionId: string | undefined; if (evalCase.messages) { @@ -188,6 +135,37 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) { await evalCase.assert(rig, result); isSuccess = true; + return; // Success! Exit the retry loop. + } catch (error: unknown) { + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorCode = getApiErrorCode(errorMessage); + + if (errorCode) { + const status = attempt < maxRetries ? 'RETRY' : 'SKIP'; + logReliabilityEvent( + evalCase.name, + attempt, + status, + errorCode, + errorMessage, + ); + + if (attempt < maxRetries) { + attempt++; + console.warn( + `[Eval] Attempt ${attempt} failed with ${errorCode} Error. Retrying...`, + ); + continue; // Retry + } + + console.warn( + `[Eval] '${evalCase.name}' failed after ${maxRetries} retries due to persistent API errors. Skipping failure to avoid blocking PR.`, + ); + return; // Gracefully exit without failing the test + } + + throw error; // Real failure } finally { if (isSuccess) { await fs.promises.unlink(activityLogFile).catch((err) => { @@ -206,9 +184,131 @@ export function evalTest(policy: EvalPolicy, evalCase: EvalCase) { ); await rig.cleanup(); } + } +} + +function getApiErrorCode(message: string): '500' | '503' | undefined { + if ( + message.includes('status: UNAVAILABLE') || + message.includes('code: 503') || + message.includes('Service Unavailable') + ) { + return '503'; + } + if ( + message.includes('status: INTERNAL') || + message.includes('code: 500') || + message.includes('Internal error encountered') + ) { + return '500'; + } + return undefined; +} + +/** + * Log reliability event for later harvesting. + * + * Note: Uses synchronous file I/O to ensure the log is persisted even if the + * test process is abruptly terminated by a timeout or CI crash. Performance + * impact is negligible compared to long-running evaluation tests. + */ +function logReliabilityEvent( + testName: string, + attempt: number, + status: 'RETRY' | 'SKIP', + errorCode: '500' | '503', + errorMessage: string, +) { + const reliabilityLog = { + timestamp: new Date().toISOString(), + testName, + model: process.env.GEMINI_MODEL || 'unknown', + attempt, + status, + errorCode, + error: errorMessage, }; - runEval(policy, evalCase.name, fn, evalCase.timeout); + try { + const relDir = path.resolve(process.cwd(), 'evals/logs'); + fs.mkdirSync(relDir, { recursive: true }); + fs.appendFileSync( + path.join(relDir, 'api-reliability.jsonl'), + JSON.stringify(reliabilityLog) + '\n', + ); + } catch (logError) { + console.error('Failed to write reliability log:', logError); + } +} + +/** + * Helper to setup test files and git repository. + * + * Note: While this is an async function (due to parseAgentMarkdown), it + * intentionally uses synchronous filesystem and child_process operations + * for simplicity and to ensure sequential environment preparation. + */ +async function setupTestFiles(rig: TestRig, files: Record) { + const acknowledgedAgents: Record> = {}; + const projectRoot = fs.realpathSync(rig.testDir!); + + for (const [filePath, content] of Object.entries(files)) { + if (filePath.includes('..') || path.isAbsolute(filePath)) { + throw new Error(`Invalid file path in test case: ${filePath}`); + } + const fullPath = path.join(projectRoot, filePath); + if (!fullPath.startsWith(projectRoot)) { + throw new Error(`Path traversal detected: ${filePath}`); + } + + fs.mkdirSync(path.dirname(fullPath), { recursive: true }); + fs.writeFileSync(fullPath, content); + + if (filePath.startsWith('.gemini/agents/') && filePath.endsWith('.md')) { + const hash = crypto.createHash('sha256').update(content).digest('hex'); + try { + const agentDefs = await parseAgentMarkdown(fullPath, content); + if (agentDefs.length > 0) { + const agentName = agentDefs[0].name; + if (!acknowledgedAgents[projectRoot]) { + acknowledgedAgents[projectRoot] = {}; + } + acknowledgedAgents[projectRoot][agentName] = hash; + } + } catch (error) { + console.warn( + `Failed to parse agent for test acknowledgement: ${filePath}`, + error, + ); + } + } + } + + if (Object.keys(acknowledgedAgents).length > 0) { + const ackPath = path.join( + rig.homeDir!, + '.gemini', + 'acknowledgments', + 'agents.json', + ); + fs.mkdirSync(path.dirname(ackPath), { recursive: true }); + fs.writeFileSync(ackPath, JSON.stringify(acknowledgedAgents, null, 2)); + } + + const execOptions = { cwd: rig.testDir!, stdio: 'inherit' as const }; + execSync('git init --initial-branch=main', execOptions); + execSync('git config user.email "test@example.com"', execOptions); + execSync('git config user.name "Test User"', execOptions); + + // Temporarily disable the interactive editor and git pager + // to avoid hanging the tests. It seems the the agent isn't + // consistently honoring the instructions to avoid interactive + // commands. + execSync('git config core.editor "true"', execOptions); + execSync('git config core.pager "cat"', execOptions); + execSync('git config commit.gpgsign false', execOptions); + execSync('git add .', execOptions); + execSync('git commit --allow-empty -m "Initial commit"', execOptions); } /** diff --git a/evals/vitest.config.ts b/evals/vitest.config.ts index 3231f31a10..50733a999c 100644 --- a/evals/vitest.config.ts +++ b/evals/vitest.config.ts @@ -16,10 +16,6 @@ export default defineConfig({ }, test: { testTimeout: 300000, // 5 minutes - // Retry in CI but not nightly to avoid blocking on API error. - retry: process.env['VITEST_RETRY'] - ? parseInt(process.env['VITEST_RETRY'], 10) - : 3, reporters: ['default', 'json'], outputFile: { json: 'evals/logs/report.json', diff --git a/scripts/harvest_api_reliability.sh b/scripts/harvest_api_reliability.sh new file mode 100755 index 0000000000..140063b8ea --- /dev/null +++ b/scripts/harvest_api_reliability.sh @@ -0,0 +1,117 @@ +#!/bin/bash + +# Gemini API Reliability Harvester +# ------------------------------- +# This script gathers data about 500 API errors encountered during evaluation runs +# (eval.yml) from GitHub Actions. It is used to analyze developer friction caused +# by transient API failures. +# +# Usage: +# ./scripts/harvest_api_reliability.sh [SINCE] [LIMIT] [BRANCH] +# +# Examples: +# ./scripts/harvest_api_reliability.sh # Last 7 days, all branches +# ./scripts/harvest_api_reliability.sh 14d 500 # Last 14 days, limit 500 +# ./scripts/harvest_api_reliability.sh 2026-03-01 100 my-branch # Specific date and branch +# +# Prerequisites: +# - GitHub CLI (gh) installed and authenticated (`gh auth login`) +# - jq installed + +# Arguments & Defaults +if [[ -n "$1" && $1 =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + SINCE="$1" +elif [[ -n "$1" && $1 =~ ^([0-9]+)d$ ]]; then + DAYS="${BASH_REMATCH[1]}" + if [[ "$OSTYPE" == "darwin"* ]]; then + SINCE=$(date -u -v-"${DAYS}"d +%Y-%m-%d) + else + SINCE=$(date -u -d "${DAYS} days ago" +%Y-%m-%d) + fi +else + # Default to 7 days ago in YYYY-MM-DD format (UTC) + if [[ "$OSTYPE" == "darwin"* ]]; then + SINCE=$(date -u -v-7d +%Y-%m-%d) + else + SINCE=$(date -u -d "7 days ago" +%Y-%m-%d) + fi +fi + +LIMIT=${2:-300} +BRANCH=${3:-""} +WORKFLOWS=("Testing: E2E (Chained)" "Evals: Nightly") +DEST_DIR=$(mktemp -d -t gemini-reliability-XXXXXX) +MERGED_FILE="api-reliability-summary.jsonl" + +# Ensure cleanup on exit +trap 'rm -rf "$DEST_DIR"' EXIT + +if ! command -v gh &> /dev/null; then + echo "❌ Error: GitHub CLI (gh) is not installed." + exit 1 +fi + +if ! command -v jq &> /dev/null; then + echo "❌ Error: jq is not installed." + exit 1 +fi + +# Clean start +rm -f "$MERGED_FILE" + +# gh run list --created expects a date (YYYY-MM-DD) or a range +CREATED_QUERY=">=$SINCE" + +for WORKFLOW in "${WORKFLOWS[@]}"; do + echo "🔍 Fetching runs for '$WORKFLOW' created since $SINCE (max $LIMIT runs, branch: ${BRANCH:-all})..." + + # Construct arguments for gh run list + GH_ARGS=("--workflow" "$WORKFLOW" "--created" "$CREATED_QUERY" "--limit" "$LIMIT" "--json" "databaseId" "--jq" ".[].databaseId") + if [ -n "$BRANCH" ]; then + GH_ARGS+=("--branch" "$BRANCH") + fi + + RUN_IDS=$(gh run list "${GH_ARGS[@]}") + exit_code=$? + + if [ $exit_code -ne 0 ]; then + echo "❌ Failed to fetch runs for '$WORKFLOW' (exit code: $exit_code). Please check 'gh auth status' and permissions." >&2 + continue + fi + + if [ -z "$RUN_IDS" ]; then + echo "📭 No runs found for workflow '$WORKFLOW' since $SINCE." + continue + fi + + for ID in $RUN_IDS; do + # Download artifacts named 'eval-logs-*' + # Silencing output because many older runs won't have artifacts + gh run download "$ID" -p "eval-logs-*" -D "$DEST_DIR/$ID" &>/dev/null || continue + + # Append to master log + # Use find to locate api-reliability.jsonl in any subdirectory of $DEST_DIR/$ID + find "$DEST_DIR/$ID" -type f -name "api-reliability.jsonl" -exec cat {} + >> "$MERGED_FILE" 2>/dev/null + done +done + +if [ ! -f "$MERGED_FILE" ]; then + echo "📭 No reliability data found in the retrieved logs." + exit 0 +fi + +echo -e "\n✅ Harvest Complete! Data merged into: $MERGED_FILE" +echo "------------------------------------------------" +echo "📊 Gemini API Reliability Summary (Since $SINCE)" +echo "------------------------------------------------" + +cat "$MERGED_FILE" | jq -s ' + group_by(.model) | map({ + model: .[0].model, + "500s": (map(select(.errorCode == "500")) | length), + "503s": (map(select(.errorCode == "503")) | length), + retries: (map(select(.status == "RETRY")) | length), + skips: (map(select(.status == "SKIP")) | length) + })' + +echo -e "\n💡 Total events captured: $(wc -l < "$MERGED_FILE")" From 30e0ab102a22dd8a93c06fda320be147a120b00d Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:58:45 -0700 Subject: [PATCH 31/49] feat(sandbox): dynamic Linux sandbox expansion and worktree support (#23692) Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../sandbox/linux/LinuxSandboxManager.test.ts | 274 +++++++------- .../src/sandbox/linux/LinuxSandboxManager.ts | 342 ++++++++++-------- .../sandbox/macos/MacOsSandboxManager.test.ts | 2 +- .../src/sandbox/macos/MacOsSandboxManager.ts | 17 +- .../sandbox/macos/seatbeltArgsBuilder.test.ts | 123 +++---- .../src/sandbox/macos/seatbeltArgsBuilder.ts | 64 ++-- .../sandbox/{macos => utils}/commandSafety.ts | 0 .../core/src/sandbox/utils/commandUtils.ts | 82 +++++ packages/core/src/sandbox/utils/fsUtils.ts | 92 +++++ .../windows/WindowsSandboxManager.test.ts | 2 +- .../sandbox/windows/WindowsSandboxManager.ts | 13 +- packages/core/src/services/sandboxManager.ts | 2 +- .../src/services/sandboxManagerFactory.ts | 6 +- 13 files changed, 604 insertions(+), 415 deletions(-) rename packages/core/src/sandbox/{macos => utils}/commandSafety.ts (100%) create mode 100644 packages/core/src/sandbox/utils/commandUtils.ts create mode 100644 packages/core/src/sandbox/utils/fsUtils.ts diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts index 5bde6a44da..b58fe271f6 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts @@ -6,7 +6,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { LinuxSandboxManager } from './LinuxSandboxManager.js'; -import * as sandboxManager from '../../services/sandboxManager.js'; import type { SandboxRequest } from '../../services/sandboxManager.js'; import fs from 'node:fs'; @@ -18,14 +17,16 @@ vi.mock('node:fs', async () => { // @ts-expect-error - Property 'default' does not exist on type 'typeof import("node:fs")' ...actual.default, existsSync: vi.fn(() => true), - realpathSync: vi.fn((p: string | Buffer) => p.toString()), + realpathSync: vi.fn((p) => p.toString()), + statSync: vi.fn(() => ({ isDirectory: () => true }) as fs.Stats), mkdirSync: vi.fn(), openSync: vi.fn(), closeSync: vi.fn(), writeFileSync: vi.fn(), }, existsSync: vi.fn(() => true), - realpathSync: vi.fn((p: string | Buffer) => p.toString()), + realpathSync: vi.fn((p) => p.toString()), + statSync: vi.fn(() => ({ isDirectory: () => true }) as fs.Stats), mkdirSync: vi.fn(), openSync: vi.fn(), closeSync: vi.fn(), @@ -48,8 +49,12 @@ describe('LinuxSandboxManager', () => { vi.restoreAllMocks(); }); - const getBwrapArgs = async (req: SandboxRequest) => { - const result = await manager.prepareCommand(req); + const getBwrapArgs = async ( + req: SandboxRequest, + customManager?: LinuxSandboxManager, + ) => { + const mgr = customManager || manager; + const result = await mgr.prepareCommand(req); expect(result.program).toBe('sh'); expect(result.args[0]).toBe('-c'); expect(result.args[1]).toBe( @@ -60,41 +65,6 @@ describe('LinuxSandboxManager', () => { return result.args.slice(4); }; - /** - * Helper to verify only the dynamic, policy-based binds (e.g. allowedPaths, forbiddenPaths). - * It asserts that the base workspace and governance files are present exactly once, - * then strips them away, leaving only the dynamic binds for a focused, non-brittle assertion. - */ - const expectDynamicBinds = ( - bwrapArgs: string[], - expectedDynamicBinds: string[], - ) => { - const bindsIndex = bwrapArgs.indexOf('--seccomp'); - const allBinds = bwrapArgs.slice(bwrapArgs.indexOf('--bind'), bindsIndex); - - const baseBinds = [ - '--bind', - workspace, - workspace, - '--ro-bind', - `${workspace}/.gitignore`, - `${workspace}/.gitignore`, - '--ro-bind', - `${workspace}/.geminiignore`, - `${workspace}/.geminiignore`, - '--ro-bind', - `${workspace}/.git`, - `${workspace}/.git`, - ]; - - // Verify the base binds are present exactly at the beginning - expect(allBinds.slice(0, baseBinds.length)).toEqual(baseBinds); - - // Extract the remaining dynamic binds - const dynamicBinds = allBinds.slice(baseBinds.length); - expect(dynamicBinds).toEqual(expectedDynamicBinds); - }; - describe('prepareCommand', () => { it('should correctly format the base command and args', async () => { const bwrapArgs = await getBwrapArgs({ @@ -117,7 +87,7 @@ describe('LinuxSandboxManager', () => { '/proc', '--tmpfs', '/tmp', - '--bind', + '--ro-bind-try', workspace, workspace, '--ro-bind', @@ -137,6 +107,73 @@ describe('LinuxSandboxManager', () => { ]); }); + it('binds workspace read-write when readonly is false', async () => { + const customManager = new LinuxSandboxManager({ + workspace, + modeConfig: { readonly: false }, + }); + const bwrapArgs = await getBwrapArgs( + { + command: 'ls', + args: [], + cwd: workspace, + env: {}, + }, + customManager, + ); + + expect(bwrapArgs).toContain('--bind-try'); + expect(bwrapArgs).toContain(workspace); + }); + + it('maps network permissions to --share-net', async () => { + const bwrapArgs = await getBwrapArgs({ + command: 'curl', + args: [], + cwd: workspace, + env: {}, + policy: { additionalPermissions: { network: true } }, + }); + + expect(bwrapArgs).toContain('--share-net'); + }); + + it('maps explicit write permissions to --bind-try', async () => { + const bwrapArgs = await getBwrapArgs({ + command: 'touch', + args: [], + cwd: workspace, + env: {}, + policy: { + additionalPermissions: { + fileSystem: { write: ['/home/user/workspace/out/dir'] }, + }, + }, + }); + + const index = bwrapArgs.indexOf('--bind-try'); + expect(index).not.toBe(-1); + expect(bwrapArgs[index + 1]).toBe('/home/user/workspace/out/dir'); + }); + + it('rejects overrides in plan mode', async () => { + const customManager = new LinuxSandboxManager({ + workspace, + modeConfig: { allowOverrides: false }, + }); + await expect( + customManager.prepareCommand({ + command: 'ls', + args: [], + cwd: workspace, + env: {}, + policy: { additionalPermissions: { network: true } }, + }), + ).rejects.toThrow( + /Cannot override readonly\/network\/filesystem restrictions in Plan mode/, + ); + }); + it('should correctly pass through the cwd to the resulting command', async () => { const req: SandboxRequest = { command: 'ls', @@ -184,12 +221,7 @@ describe('LinuxSandboxManager', () => { }, }); - expect(bwrapArgs).toContain('--unshare-user'); - expect(bwrapArgs).toContain('--unshare-ipc'); - expect(bwrapArgs).toContain('--unshare-pid'); - expect(bwrapArgs).toContain('--unshare-uts'); - expect(bwrapArgs).toContain('--unshare-cgroup'); - expect(bwrapArgs).not.toContain('--unshare-all'); + expect(bwrapArgs).toContain('--share-net'); }); describe('governance files', () => { @@ -252,15 +284,32 @@ describe('LinuxSandboxManager', () => { }, }); - // Verify the specific bindings were added correctly - expectDynamicBinds(bwrapArgs, [ + expect(bwrapArgs).toContain('--bind-try'); + expect(bwrapArgs[bwrapArgs.indexOf('/tmp/cache') - 1]).toBe( '--bind-try', - '/tmp/cache', - '/tmp/cache', + ); + expect(bwrapArgs[bwrapArgs.indexOf('/opt/tools') - 1]).toBe( '--bind-try', - '/opt/tools', - '/opt/tools', - ]); + ); + }); + + it('should not grant read-write access to allowedPaths inside the workspace when readonly mode is active', async () => { + const manager = new LinuxSandboxManager({ + workspace, + modeConfig: { readonly: true }, + }); + const result = await manager.prepareCommand({ + command: 'ls', + args: [], + cwd: workspace, + env: {}, + policy: { + allowedPaths: [workspace + '/subdirectory'], + }, + }); + const bwrapArgs = result.args; + const bindIndex = bwrapArgs.indexOf(workspace + '/subdirectory'); + expect(bwrapArgs[bindIndex - 1]).toBe('--ro-bind-try'); }); it('should not bind the workspace twice even if it has a trailing slash in allowedPaths', async () => { @@ -274,23 +323,20 @@ describe('LinuxSandboxManager', () => { }, }); - // Should only contain the primary workspace bind and governance files, not the second workspace bind with a trailing slash - expectDynamicBinds(bwrapArgs, []); + const binds = bwrapArgs.filter((a) => a === workspace); + expect(binds.length).toBe(2); }); }); describe('forbiddenPaths', () => { it('should parameterize forbidden paths and explicitly deny them', async () => { - vi.spyOn(fs.promises, 'stat').mockImplementation(async (p) => { - // Mock /tmp/cache as a directory, and /opt/secret.txt as a file + vi.mocked(fs.statSync).mockImplementation((p) => { if (p.toString().includes('cache')) { return { isDirectory: () => true } as fs.Stats; } return { isDirectory: () => false } as fs.Stats; }); - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => - p.toString(), - ); + vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString()); const bwrapArgs = await getBwrapArgs({ command: 'ls', @@ -302,27 +348,22 @@ describe('LinuxSandboxManager', () => { }, }); - expectDynamicBinds(bwrapArgs, [ - '--tmpfs', - '/tmp/cache', - '--remount-ro', - '/tmp/cache', - '--ro-bind-try', - '/dev/null', - '/opt/secret.txt', - ]); + const cacheIndex = bwrapArgs.indexOf('/tmp/cache'); + expect(bwrapArgs[cacheIndex - 1]).toBe('--tmpfs'); + + const secretIndex = bwrapArgs.indexOf('/opt/secret.txt'); + expect(bwrapArgs[secretIndex - 2]).toBe('--ro-bind'); + expect(bwrapArgs[secretIndex - 1]).toBe('/dev/null'); }); it('resolves forbidden symlink paths to their real paths', async () => { - vi.spyOn(fs.promises, 'stat').mockImplementation( - async () => ({ isDirectory: () => false }) as fs.Stats, - ); - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => { - if (p === '/tmp/forbidden-symlink') return '/opt/real-target.txt'; - return p.toString(); - }, + vi.mocked(fs.statSync).mockImplementation( + () => ({ isDirectory: () => false }) as fs.Stats, ); + vi.mocked(fs.realpathSync).mockImplementation((p) => { + if (p === '/tmp/forbidden-symlink') return '/opt/real-target.txt'; + return p.toString(); + }); const bwrapArgs = await getBwrapArgs({ command: 'ls', @@ -334,24 +375,18 @@ describe('LinuxSandboxManager', () => { }, }); - // Should explicitly mask both the resolved path and the original symlink path - expectDynamicBinds(bwrapArgs, [ - '--ro-bind-try', - '/dev/null', - '/opt/real-target.txt', - '--ro-bind-try', - '/dev/null', - '/tmp/forbidden-symlink', - ]); + const secretIndex = bwrapArgs.indexOf('/opt/real-target.txt'); + expect(bwrapArgs[secretIndex - 2]).toBe('--ro-bind'); + expect(bwrapArgs[secretIndex - 1]).toBe('/dev/null'); }); it('explicitly denies non-existent forbidden paths to prevent creation', async () => { const error = new Error('File not found') as NodeJS.ErrnoException; error.code = 'ENOENT'; - vi.spyOn(fs.promises, 'stat').mockRejectedValue(error); - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => - p.toString(), - ); + vi.mocked(fs.statSync).mockImplementation(() => { + throw error; + }); + vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString()); const bwrapArgs = await getBwrapArgs({ command: 'ls', @@ -363,23 +398,19 @@ describe('LinuxSandboxManager', () => { }, }); - expectDynamicBinds(bwrapArgs, [ - '--symlink', - '/.forbidden', - '/tmp/not-here.txt', - ]); + const idx = bwrapArgs.indexOf('/tmp/not-here.txt'); + expect(bwrapArgs[idx - 2]).toBe('--symlink'); + expect(bwrapArgs[idx - 1]).toBe('/dev/null'); }); it('masks directory symlinks with tmpfs for both paths', async () => { - vi.spyOn(fs.promises, 'stat').mockImplementation( - async () => ({ isDirectory: () => true }) as fs.Stats, - ); - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => { - if (p === '/tmp/dir-link') return '/opt/real-dir'; - return p.toString(); - }, + vi.mocked(fs.statSync).mockImplementation( + () => ({ isDirectory: () => true }) as fs.Stats, ); + vi.mocked(fs.realpathSync).mockImplementation((p) => { + if (p === '/tmp/dir-link') return '/opt/real-dir'; + return p.toString(); + }); const bwrapArgs = await getBwrapArgs({ command: 'ls', @@ -391,25 +422,15 @@ describe('LinuxSandboxManager', () => { }, }); - expectDynamicBinds(bwrapArgs, [ - '--tmpfs', - '/opt/real-dir', - '--remount-ro', - '/opt/real-dir', - '--tmpfs', - '/tmp/dir-link', - '--remount-ro', - '/tmp/dir-link', - ]); + const idx = bwrapArgs.indexOf('/opt/real-dir'); + expect(bwrapArgs[idx - 1]).toBe('--tmpfs'); }); it('should override allowed paths if a path is also in forbidden paths', async () => { - vi.spyOn(fs.promises, 'stat').mockImplementation( - async () => ({ isDirectory: () => true }) as fs.Stats, - ); - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => - p.toString(), + vi.mocked(fs.statSync).mockImplementation( + () => ({ isDirectory: () => true }) as fs.Stats, ); + vi.mocked(fs.realpathSync).mockImplementation((p) => p.toString()); const bwrapArgs = await getBwrapArgs({ command: 'ls', @@ -422,15 +443,12 @@ describe('LinuxSandboxManager', () => { }, }); - expectDynamicBinds(bwrapArgs, [ - '--bind-try', - '/tmp/conflict', - '/tmp/conflict', - '--tmpfs', - '/tmp/conflict', - '--remount-ro', - '/tmp/conflict', - ]); + const bindTryIdx = bwrapArgs.indexOf('--bind-try'); + const tmpfsIdx = bwrapArgs.lastIndexOf('--tmpfs'); + + expect(bwrapArgs[bindTryIdx + 1]).toBe('/tmp/conflict'); + expect(bwrapArgs[tmpfsIdx + 1]).toBe('/tmp/conflict'); + expect(tmpfsIdx).toBeGreaterThan(bindTryIdx); }); }); }); diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts index 2b3e8cc7c9..33f12beafa 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts @@ -5,6 +5,7 @@ */ import fs from 'node:fs'; +import { debugLogger } from '../../utils/debugLogger.js'; import { join, dirname, normalize } from 'node:path'; import os from 'node:os'; import { @@ -12,15 +13,25 @@ import { type GlobalSandboxOptions, type SandboxRequest, type SandboxedCommand, + type SandboxPermissions, GOVERNANCE_FILES, sanitizePaths, - tryRealpath, } from '../../services/sandboxManager.js'; import { sanitizeEnvironment, getSecureSanitizationConfig, } from '../../services/environmentSanitization.js'; -import { isNodeError } from '../../utils/errors.js'; +import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js'; +import { + isStrictlyApproved, + verifySandboxOverrides, + getCommandName, +} from '../utils/commandUtils.js'; +import { + tryRealpath, + resolveGitWorktreePaths, + isErrnoException, +} from '../utils/fsUtils.js'; let cachedBpfPath: string | undefined; @@ -102,13 +113,24 @@ function touch(filePath: string, isDirectory: boolean) { import { isKnownSafeCommand, isDangerousCommand, -} from '../macos/commandSafety.js'; +} from '../utils/commandSafety.js'; /** * A SandboxManager implementation for Linux that uses Bubblewrap (bwrap). */ + +export interface LinuxSandboxOptions extends GlobalSandboxOptions { + modeConfig?: { + readonly?: boolean; + network?: boolean; + approvedTools?: string[]; + allowOverrides?: boolean; + }; + policyManager?: SandboxPolicyManager; +} + export class LinuxSandboxManager implements SandboxManager { - constructor(private readonly options: GlobalSandboxOptions) {} + constructor(private readonly options: LinuxSandboxOptions) {} isKnownSafeCommand(args: string[]): boolean { return isKnownSafeCommand(args); @@ -119,6 +141,41 @@ export class LinuxSandboxManager implements SandboxManager { } async prepareCommand(req: SandboxRequest): Promise { + const isReadonlyMode = this.options.modeConfig?.readonly ?? true; + const allowOverrides = this.options.modeConfig?.allowOverrides ?? true; + + verifySandboxOverrides(allowOverrides, req.policy); + + const commandName = await getCommandName(req); + const isApproved = allowOverrides + ? await isStrictlyApproved(req, this.options.modeConfig?.approvedTools) + : false; + const workspaceWrite = !isReadonlyMode || isApproved; + const networkAccess = + this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false; + + const persistentPermissions = allowOverrides + ? this.options.policyManager?.getCommandPermissions(commandName) + : undefined; + + const mergedAdditional: SandboxPermissions = { + fileSystem: { + read: [ + ...(persistentPermissions?.fileSystem?.read ?? []), + ...(req.policy?.additionalPermissions?.fileSystem?.read ?? []), + ], + write: [ + ...(persistentPermissions?.fileSystem?.write ?? []), + ...(req.policy?.additionalPermissions?.fileSystem?.write ?? []), + ], + }, + network: + networkAccess || + persistentPermissions?.network || + req.policy?.additionalPermissions?.network || + false, + }; + const sanitizationConfig = getSecureSanitizationConfig( req.policy?.sanitizationConfig, ); @@ -126,13 +183,142 @@ export class LinuxSandboxManager implements SandboxManager { const sanitizedEnv = sanitizeEnvironment(req.env, sanitizationConfig); const bwrapArgs: string[] = [ - ...this.getNetworkArgs(req), - ...this.getBaseArgs(), - ...this.getGovernanceArgs(), - ...this.getAllowedPathsArgs(req.policy?.allowedPaths), - ...(await this.getForbiddenPathsArgs(req.policy?.forbiddenPaths)), + '--unshare-all', + '--new-session', // Isolate session + '--die-with-parent', // Prevent orphaned runaway processes ]; + if (mergedAdditional.network) { + bwrapArgs.push('--share-net'); + } + + bwrapArgs.push( + '--ro-bind', + '/', + '/', + '--dev', // Creates a safe, minimal /dev (replaces --dev-bind) + '/dev', + '--proc', // Creates a fresh procfs for the unshared PID namespace + '/proc', + '--tmpfs', // Provides an isolated, writable /tmp directory + '/tmp', + ); + + const workspacePath = tryRealpath(this.options.workspace); + + const bindFlag = workspaceWrite ? '--bind-try' : '--ro-bind-try'; + + if (workspaceWrite) { + bwrapArgs.push( + '--bind-try', + this.options.workspace, + this.options.workspace, + ); + if (workspacePath !== this.options.workspace) { + bwrapArgs.push('--bind-try', workspacePath, workspacePath); + } + } else { + bwrapArgs.push( + '--ro-bind-try', + this.options.workspace, + this.options.workspace, + ); + if (workspacePath !== this.options.workspace) { + bwrapArgs.push('--ro-bind-try', workspacePath, workspacePath); + } + } + + const { worktreeGitDir, mainGitDir } = + resolveGitWorktreePaths(workspacePath); + if (worktreeGitDir) { + bwrapArgs.push(bindFlag, worktreeGitDir, worktreeGitDir); + } + if (mainGitDir) { + bwrapArgs.push(bindFlag, mainGitDir, mainGitDir); + } + + const allowedPaths = sanitizePaths(req.policy?.allowedPaths) || []; + const normalizedWorkspace = normalize(workspacePath).replace(/\/$/, ''); + for (const allowedPath of allowedPaths) { + const resolved = tryRealpath(allowedPath); + if (!fs.existsSync(resolved)) continue; + const normalizedAllowedPath = normalize(resolved).replace(/\/$/, ''); + if (normalizedAllowedPath !== normalizedWorkspace) { + if ( + !workspaceWrite && + normalizedAllowedPath.startsWith(normalizedWorkspace + '/') + ) { + bwrapArgs.push('--ro-bind-try', resolved, resolved); + } else { + bwrapArgs.push('--bind-try', resolved, resolved); + } + } + } + + const additionalReads = + sanitizePaths(mergedAdditional.fileSystem?.read) || []; + for (const p of additionalReads) { + try { + const safeResolvedPath = tryRealpath(p); + bwrapArgs.push('--ro-bind-try', safeResolvedPath, safeResolvedPath); + } catch (e: unknown) { + debugLogger.warn(e instanceof Error ? e.message : String(e)); + } + } + + const additionalWrites = + sanitizePaths(mergedAdditional.fileSystem?.write) || []; + for (const p of additionalWrites) { + try { + const safeResolvedPath = tryRealpath(p); + bwrapArgs.push('--bind-try', safeResolvedPath, safeResolvedPath); + } catch (e: unknown) { + debugLogger.warn(e instanceof Error ? e.message : String(e)); + } + } + + for (const file of GOVERNANCE_FILES) { + const filePath = join(this.options.workspace, file.path); + touch(filePath, file.isDirectory); + const realPath = tryRealpath(filePath); + bwrapArgs.push('--ro-bind', filePath, filePath); + if (realPath !== filePath) { + bwrapArgs.push('--ro-bind', realPath, realPath); + } + } + + const forbiddenPaths = sanitizePaths(req.policy?.forbiddenPaths) || []; + for (const p of forbiddenPaths) { + let resolved: string; + try { + resolved = tryRealpath(p); // Forbidden paths should still resolve to block the real path + if (!fs.existsSync(resolved)) continue; + } catch (e: unknown) { + debugLogger.warn( + `Failed to resolve forbidden path ${p}: ${e instanceof Error ? e.message : String(e)}`, + ); + bwrapArgs.push('--ro-bind', '/dev/null', p); + continue; + } + try { + const stat = fs.statSync(resolved); + if (stat.isDirectory()) { + bwrapArgs.push('--tmpfs', resolved, '--remount-ro', resolved); + } else { + bwrapArgs.push('--ro-bind', '/dev/null', resolved); + } + } catch (e: unknown) { + if (isErrnoException(e) && e.code === 'ENOENT') { + bwrapArgs.push('--symlink', '/dev/null', resolved); + } else { + debugLogger.warn( + `Failed to stat forbidden path ${resolved}: ${e instanceof Error ? e.message : String(e)}`, + ); + bwrapArgs.push('--ro-bind', '/dev/null', resolved); + } + } + } + const bpfPath = getSeccompBpfPath(); bwrapArgs.push('--seccomp', '9'); @@ -153,142 +339,4 @@ export class LinuxSandboxManager implements SandboxManager { cwd: req.cwd, }; } - - /** - * Generates arguments for network isolation. - */ - private getNetworkArgs(req: SandboxRequest): string[] { - return req.policy?.networkAccess - ? [ - '--unshare-user', - '--unshare-ipc', - '--unshare-pid', - '--unshare-uts', - '--unshare-cgroup', - ] - : ['--unshare-all']; - } - - /** - * Generates the base bubblewrap arguments for isolation. - */ - private getBaseArgs(): string[] { - return [ - '--new-session', // Isolate session - '--die-with-parent', // Prevent orphaned runaway processes - '--ro-bind', - '/', - '/', - '--dev', // Creates a safe, minimal /dev (replaces --dev-bind) - '/dev', - '--proc', // Creates a fresh procfs for the unshared PID namespace - '/proc', - '--tmpfs', // Provides an isolated, writable /tmp directory - '/tmp', - // Note: --dev /dev sets up /dev/pts automatically - '--bind', - this.options.workspace, - this.options.workspace, - ]; - } - - /** - * Generates arguments for protected governance files. - */ - private getGovernanceArgs(): string[] { - const args: string[] = []; - // Protected governance files are bind-mounted as read-only, even if the workspace is RW. - // We ensure they exist on the host and resolve real paths to prevent symlink bypasses. - // In bwrap, later binds override earlier ones for the same path. - for (const file of GOVERNANCE_FILES) { - const filePath = join(this.options.workspace, file.path); - touch(filePath, file.isDirectory); - - const realPath = fs.realpathSync(filePath); - - args.push('--ro-bind', filePath, filePath); - if (realPath !== filePath) { - args.push('--ro-bind', realPath, realPath); - } - } - return args; - } - - /** - * Generates arguments for allowed paths. - */ - private getAllowedPathsArgs(allowedPaths?: string[]): string[] { - const args: string[] = []; - const paths = sanitizePaths(allowedPaths) || []; - const normalizedWorkspace = this.normalizePath(this.options.workspace); - - for (const p of paths) { - if (this.normalizePath(p) !== normalizedWorkspace) { - args.push('--bind-try', p, p); - } - } - return args; - } - - /** - * Generates arguments for forbidden paths. - */ - private async getForbiddenPathsArgs( - forbiddenPaths?: string[], - ): Promise { - const args: string[] = []; - const paths = sanitizePaths(forbiddenPaths) || []; - - for (const p of paths) { - try { - const originalPath = this.normalizePath(p); - const resolvedPath = await tryRealpath(originalPath); - - // Mask the resolved path to prevent access to the underlying file. - const resolvedMask = await this.getMaskArgs(resolvedPath); - args.push(...resolvedMask); - - // If the original path was a symlink, mask it as well to prevent access - // through the link itself. - if (resolvedPath !== originalPath) { - const originalMask = await this.getMaskArgs(originalPath); - args.push(...originalMask); - } - } catch (e) { - throw new Error( - `Failed to deny access to forbidden path: ${p}. ${ - e instanceof Error ? e.message : String(e) - }`, - ); - } - } - return args; - } - - /** - * Generates bubblewrap arguments to mask a forbidden path. - */ - private async getMaskArgs(path: string): Promise { - try { - const stats = await fs.promises.stat(path); - - if (stats.isDirectory()) { - // Directories are masked by mounting an empty, read-only tmpfs. - return ['--tmpfs', path, '--remount-ro', path]; - } - // Existing files are masked by binding them to /dev/null. - return ['--ro-bind-try', '/dev/null', path]; - } catch (e) { - if (isNodeError(e) && e.code === 'ENOENT') { - // Non-existent paths are masked by a broken symlink. This prevents - // creation within the sandbox while avoiding host remnants. - return ['--symlink', '/.forbidden', path]; - } - throw e; - } - } - - private normalizePath(p: string): string { - return normalize(p).replace(/\/$/, ''); - } } diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts index 0c7e83ecfe..3f23a22553 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts @@ -38,7 +38,7 @@ describe('MacOsSandboxManager', () => { manager = new MacOsSandboxManager({ workspace: mockWorkspace }); // Mock the seatbelt args builder to isolate manager tests - vi.spyOn(seatbeltArgsBuilder, 'buildSeatbeltArgs').mockResolvedValue([ + vi.spyOn(seatbeltArgsBuilder, 'buildSeatbeltArgs').mockReturnValue([ '-p', '(mock profile)', '-D', diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts index c767c18b82..db2768d7c6 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts @@ -24,8 +24,9 @@ import { isKnownSafeCommand, isDangerousCommand, isStrictlyApproved, -} from './commandSafety.js'; +} from '../utils/commandSafety.js'; import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js'; +import { verifySandboxOverrides } from '../utils/commandUtils.js'; export interface MacOsSandboxOptions extends GlobalSandboxOptions { /** The current sandbox mode behavior from config. */ @@ -70,17 +71,7 @@ export class MacOsSandboxManager implements SandboxManager { const allowOverrides = this.options.modeConfig?.allowOverrides ?? true; // Reject override attempts in plan mode - if (!allowOverrides && req.policy?.additionalPermissions) { - const perms = req.policy.additionalPermissions; - if ( - perms.network || - (perms.fileSystem?.write && perms.fileSystem.write.length > 0) - ) { - throw new Error( - 'Sandbox request rejected: Cannot override readonly/network restrictions in Plan mode.', - ); - } - } + verifySandboxOverrides(allowOverrides, req.policy); // If not in readonly mode OR it's a strictly approved pipeline, allow workspace writes const isApproved = allowOverrides @@ -120,7 +111,7 @@ export class MacOsSandboxManager implements SandboxManager { false, }; - const sandboxArgs = await buildSeatbeltArgs({ + const sandboxArgs = buildSeatbeltArgs({ workspace: this.options.workspace, allowedPaths: [...(req.policy?.allowedPaths || [])], forbiddenPaths: req.policy?.forbiddenPaths, diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts index dd2c95235e..fcab494059 100644 --- a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.test.ts @@ -3,25 +3,31 @@ * Copyright 2026 Google LLC * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import { buildSeatbeltArgs } from './seatbeltArgsBuilder.js'; -import * as sandboxManager from '../../services/sandboxManager.js'; +import * as fsUtils from '../utils/fsUtils.js'; import fs from 'node:fs'; import os from 'node:os'; +vi.mock('../utils/fsUtils.js', async () => { + const actual = await vi.importActual('../utils/fsUtils.js'); + return { + ...actual, + tryRealpath: vi.fn((p) => p), + resolveGitWorktreePaths: vi.fn(() => ({})), + }; +}); + describe('seatbeltArgsBuilder', () => { - beforeEach(() => { + afterEach(() => { vi.restoreAllMocks(); }); describe('buildSeatbeltArgs', () => { - it('should build a strict allowlist profile allowing the workspace via param', async () => { - // Mock tryRealpath to just return the path for testing - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => p, - ); + it('should build a strict allowlist profile allowing the workspace via param', () => { + vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p); - const args = await buildSeatbeltArgs({ + const args = buildSeatbeltArgs({ workspace: '/Users/test/workspace', }); @@ -38,11 +44,9 @@ describe('seatbeltArgsBuilder', () => { expect(args).toContain(`TMPDIR=${os.tmpdir()}`); }); - it('should allow network when networkAccess is true', async () => { - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => p, - ); - const args = await buildSeatbeltArgs({ + it('should allow network when networkAccess is true', () => { + vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p); + const args = buildSeatbeltArgs({ workspace: '/test', networkAccess: true, }); @@ -51,10 +55,8 @@ describe('seatbeltArgsBuilder', () => { }); describe('governance files', () => { - it('should inject explicit deny rules for governance files', async () => { - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation(async (p) => - p.toString(), - ); + it('should inject explicit deny rules for governance files', () => { + vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p.toString()); vi.spyOn(fs, 'existsSync').mockReturnValue(true); vi.spyOn(fs, 'lstatSync').mockImplementation( (p) => @@ -64,35 +66,29 @@ describe('seatbeltArgsBuilder', () => { }) as unknown as fs.Stats, ); - const args = await buildSeatbeltArgs({ - workspace: '/Users/test/workspace', + const args = buildSeatbeltArgs({ + workspace: '/test/workspace', }); const profile = args[1]; - // .gitignore should be a literal deny expect(args).toContain('-D'); - expect(args).toContain( - 'GOVERNANCE_FILE_0=/Users/test/workspace/.gitignore', - ); + expect(args).toContain('GOVERNANCE_FILE_0=/test/workspace/.gitignore'); expect(profile).toContain( '(deny file-write* (literal (param "GOVERNANCE_FILE_0")))', ); - // .git should be a subpath deny - expect(args).toContain('GOVERNANCE_FILE_2=/Users/test/workspace/.git'); + expect(args).toContain('GOVERNANCE_FILE_2=/test/workspace/.git'); expect(profile).toContain( '(deny file-write* (subpath (param "GOVERNANCE_FILE_2")))', ); }); - it('should protect both the symlink and the real path if they differ', async () => { - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => { - if (p === '/test/workspace/.gitignore') - return '/test/real/.gitignore'; - return p.toString(); - }, - ); + it('should protect both the symlink and the real path if they differ', () => { + vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => { + if (p === '/test/workspace/.gitignore') + return '/test/real/.gitignore'; + return p.toString(); + }); vi.spyOn(fs, 'existsSync').mockReturnValue(true); vi.spyOn(fs, 'lstatSync').mockImplementation( () => @@ -102,7 +98,7 @@ describe('seatbeltArgsBuilder', () => { }) as unknown as fs.Stats, ); - const args = await buildSeatbeltArgs({ workspace: '/test/workspace' }); + const args = buildSeatbeltArgs({ workspace: '/test/workspace' }); const profile = args[1]; expect(args).toContain('GOVERNANCE_FILE_0=/test/workspace/.gitignore'); @@ -117,15 +113,13 @@ describe('seatbeltArgsBuilder', () => { }); describe('allowedPaths', () => { - it('should parameterize allowed paths and normalize them', async () => { - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => { - if (p === '/test/symlink') return '/test/real_path'; - return p; - }, - ); + it('should parameterize allowed paths and normalize them', () => { + vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => { + if (p === '/test/symlink') return '/test/real_path'; + return p; + }); - const args = await buildSeatbeltArgs({ + const args = buildSeatbeltArgs({ workspace: '/test', allowedPaths: ['/custom/path1', '/test/symlink'], }); @@ -141,12 +135,10 @@ describe('seatbeltArgsBuilder', () => { }); describe('forbiddenPaths', () => { - it('should parameterize forbidden paths and explicitly deny them', async () => { - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => p, - ); + it('should parameterize forbidden paths and explicitly deny them', () => { + vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p); - const args = await buildSeatbeltArgs({ + const args = buildSeatbeltArgs({ workspace: '/test', forbiddenPaths: ['/secret/path'], }); @@ -161,22 +153,21 @@ describe('seatbeltArgsBuilder', () => { ); }); - it('resolves forbidden symlink paths to their real paths', async () => { - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => { - if (p === '/test/symlink') return '/test/real_path'; - return p; - }, - ); + it('resolves forbidden symlink paths to their real paths', () => { + vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => { + if (p === '/test/symlink' || p === '/test/missing-dir') { + return '/test/real_path'; + } + return p; + }); - const args = await buildSeatbeltArgs({ + const args = buildSeatbeltArgs({ workspace: '/test', forbiddenPaths: ['/test/symlink'], }); const profile = args[1]; - // The builder should resolve the symlink and explicitly deny the real target path expect(args).toContain('-D'); expect(args).toContain('FORBIDDEN_PATH_0=/test/real_path'); expect(profile).toContain( @@ -184,12 +175,10 @@ describe('seatbeltArgsBuilder', () => { ); }); - it('explicitly denies non-existent forbidden paths to prevent creation', async () => { - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => p, - ); + it('explicitly denies non-existent forbidden paths to prevent creation', () => { + vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p); - const args = await buildSeatbeltArgs({ + const args = buildSeatbeltArgs({ workspace: '/test', forbiddenPaths: ['/test/missing-dir/missing-file.txt'], }); @@ -205,12 +194,10 @@ describe('seatbeltArgsBuilder', () => { ); }); - it('should override allowed paths if a path is also in forbidden paths', async () => { - vi.spyOn(sandboxManager, 'tryRealpath').mockImplementation( - async (p) => p, - ); + it('should override allowed paths if a path is also in forbidden paths', () => { + vi.mocked(fsUtils.tryRealpath).mockImplementation((p) => p); - const args = await buildSeatbeltArgs({ + const args = buildSeatbeltArgs({ workspace: '/test', allowedPaths: ['/custom/path1'], forbiddenPaths: ['/custom/path1'], @@ -226,8 +213,6 @@ describe('seatbeltArgsBuilder', () => { expect(profile).toContain(allowString); expect(profile).toContain(denyString); - // Verify ordering: The explicit deny must appear AFTER the explicit allow in the profile string - // Seatbelt rules are evaluated in order where the latest rule matching a path wins const allowIndex = profile.indexOf(allowString); const denyIndex = profile.indexOf(denyString); expect(denyIndex).toBeGreaterThan(allowIndex); diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts index f72229b5cc..cfdcee1687 100644 --- a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts @@ -15,8 +15,8 @@ import { type SandboxPermissions, sanitizePaths, GOVERNANCE_FILES, - tryRealpath, } from '../../services/sandboxManager.js'; +import { tryRealpath, resolveGitWorktreePaths } from '../utils/fsUtils.js'; /** * Options for building macOS Seatbelt arguments. @@ -44,13 +44,11 @@ export interface SeatbeltArgsOptions { * Returns arguments up to the end of sandbox-exec configuration (e.g. ['-p', '', '-D', ...]) * Does not include the final '--' separator or the command to run. */ -export async function buildSeatbeltArgs( - options: SeatbeltArgsOptions, -): Promise { +export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] { let profile = BASE_SEATBELT_PROFILE + '\n'; const args: string[] = []; - const workspacePath = await tryRealpath(options.workspace); + const workspacePath = tryRealpath(options.workspace); args.push('-D', `WORKSPACE=${workspacePath}`); args.push('-D', `WORKSPACE_RAW=${options.workspace}`); profile += `(allow file-read* (subpath (param "WORKSPACE_RAW")))\n`; @@ -67,7 +65,7 @@ export async function buildSeatbeltArgs( // (Seatbelt evaluates rules in order, later rules win for same path). for (let i = 0; i < GOVERNANCE_FILES.length; i++) { const governanceFile = path.join(workspacePath, GOVERNANCE_FILES[i].path); - const realGovernanceFile = await tryRealpath(governanceFile); + const realGovernanceFile = tryRealpath(governanceFile); // Determine if it should be treated as a directory (subpath) or a file (literal). // .git is generally a directory, while ignore files are literals. @@ -92,42 +90,20 @@ export async function buildSeatbeltArgs( } // Auto-detect and support git worktrees by granting read and write access to the underlying git directory - try { - const gitPath = path.join(workspacePath, '.git'); - const gitStat = fs.lstatSync(gitPath); - if (gitStat.isFile()) { - const gitContent = fs.readFileSync(gitPath, 'utf8'); - const match = gitContent.match(/^gitdir:\s*(.+)$/m); - if (match && match[1]) { - let worktreeGitDir = match[1].trim(); - if (!path.isAbsolute(worktreeGitDir)) { - worktreeGitDir = path.resolve(workspacePath, worktreeGitDir); - } - const resolvedWorktreeGitDir = await tryRealpath(worktreeGitDir); - - // Grant write access to the worktree's specific .git directory - args.push('-D', `WORKTREE_GIT_DIR=${resolvedWorktreeGitDir}`); - profile += `(allow file-read* file-write* (subpath (param "WORKTREE_GIT_DIR")))\n`; - - // Grant write access to the main repository's .git directory (objects, refs, etc. are shared) - // resolvedWorktreeGitDir is usually like: /path/to/main-repo/.git/worktrees/worktree-name - const mainGitDir = await tryRealpath( - path.dirname(path.dirname(resolvedWorktreeGitDir)), - ); - if (mainGitDir && mainGitDir.endsWith('.git')) { - args.push('-D', `MAIN_GIT_DIR=${mainGitDir}`); - profile += `(allow file-read* file-write* (subpath (param "MAIN_GIT_DIR")))\n`; - } - } - } - } catch (_e) { - // Ignore if .git doesn't exist, isn't readable, etc. + const { worktreeGitDir, mainGitDir } = resolveGitWorktreePaths(workspacePath); + if (worktreeGitDir) { + args.push('-D', `WORKTREE_GIT_DIR=${worktreeGitDir}`); + profile += `(allow file-read* file-write* (subpath (param "WORKTREE_GIT_DIR")))\n`; + } + if (mainGitDir) { + args.push('-D', `MAIN_GIT_DIR=${mainGitDir}`); + profile += `(allow file-read* file-write* (subpath (param "MAIN_GIT_DIR")))\n`; } - const tmpPath = await tryRealpath(os.tmpdir()); + const tmpPath = tryRealpath(os.tmpdir()); args.push('-D', `TMPDIR=${tmpPath}`); - const nodeRootPath = await tryRealpath( + const nodeRootPath = tryRealpath( path.dirname(path.dirname(process.execPath)), ); args.push('-D', `NODE_ROOT=${nodeRootPath}`); @@ -142,7 +118,7 @@ export async function buildSeatbeltArgs( for (const p of paths) { if (!p.trim()) continue; try { - let resolved = await tryRealpath(p); + let resolved = tryRealpath(p); // If this is a 'bin' directory (like /usr/local/bin or homebrew/bin), // also grant read access to its parent directory so that symlinked @@ -165,8 +141,10 @@ export async function buildSeatbeltArgs( // Handle allowedPaths const allowedPaths = sanitizePaths(options.allowedPaths) || []; + const resolvedAllowedPaths: string[] = []; for (let i = 0; i < allowedPaths.length; i++) { - const allowedPath = await tryRealpath(allowedPaths[i]); + const allowedPath = tryRealpath(allowedPaths[i]); + resolvedAllowedPaths.push(allowedPath); args.push('-D', `ALLOWED_PATH_${i}=${allowedPath}`); profile += `(allow file-read* file-write* (subpath (param "ALLOWED_PATH_${i}")))\n`; } @@ -176,7 +154,7 @@ export async function buildSeatbeltArgs( const { read, write } = options.additionalPermissions.fileSystem; if (read) { for (let i = 0; i < read.length; i++) { - const resolved = await tryRealpath(read[i]); + const resolved = tryRealpath(read[i]); const paramName = `ADDITIONAL_READ_${i}`; args.push('-D', `${paramName}=${resolved}`); let isFile = false; @@ -194,7 +172,7 @@ export async function buildSeatbeltArgs( } if (write) { for (let i = 0; i < write.length; i++) { - const resolved = await tryRealpath(write[i]); + const resolved = tryRealpath(write[i]); const paramName = `ADDITIONAL_WRITE_${i}`; args.push('-D', `${paramName}=${resolved}`); let isFile = false; @@ -215,7 +193,7 @@ export async function buildSeatbeltArgs( // Handle forbiddenPaths const forbiddenPaths = sanitizePaths(options.forbiddenPaths) || []; for (let i = 0; i < forbiddenPaths.length; i++) { - const forbiddenPath = await tryRealpath(forbiddenPaths[i]); + const forbiddenPath = tryRealpath(forbiddenPaths[i]); args.push('-D', `FORBIDDEN_PATH_${i}=${forbiddenPath}`); profile += `(deny file-read* file-write* (subpath (param "FORBIDDEN_PATH_${i}")))\n`; } diff --git a/packages/core/src/sandbox/macos/commandSafety.ts b/packages/core/src/sandbox/utils/commandSafety.ts similarity index 100% rename from packages/core/src/sandbox/macos/commandSafety.ts rename to packages/core/src/sandbox/utils/commandSafety.ts diff --git a/packages/core/src/sandbox/utils/commandUtils.ts b/packages/core/src/sandbox/utils/commandUtils.ts new file mode 100644 index 0000000000..772df65afa --- /dev/null +++ b/packages/core/src/sandbox/utils/commandUtils.ts @@ -0,0 +1,82 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type SandboxRequest } from '../../services/sandboxManager.js'; +import { + getCommandRoots, + initializeShellParsers, + splitCommands, + stripShellWrapper, +} from '../../utils/shell-utils.js'; +import { isKnownSafeCommand } from './commandSafety.js'; +import { parse as shellParse } from 'shell-quote'; +import path from 'node:path'; + +export async function isStrictlyApproved( + req: SandboxRequest, + approvedTools?: string[], +): Promise { + if (!approvedTools || approvedTools.length === 0) { + return false; + } + + await initializeShellParsers(); + + const fullCmd = [req.command, ...req.args].join(' '); + const stripped = stripShellWrapper(fullCmd); + + const roots = getCommandRoots(stripped); + if (roots.length === 0) return false; + + const allRootsApproved = roots.every((root) => approvedTools.includes(root)); + if (allRootsApproved) { + return true; + } + + const pipelineCommands = splitCommands(stripped); + if (pipelineCommands.length === 0) return false; + + for (const cmdString of pipelineCommands) { + const parsedArgs = shellParse(cmdString).map(String); + if (!isKnownSafeCommand(parsedArgs)) { + return false; + } + } + + return true; +} + +export async function getCommandName(req: SandboxRequest): Promise { + await initializeShellParsers(); + const fullCmd = [req.command, ...req.args].join(' '); + const stripped = stripShellWrapper(fullCmd); + const roots = getCommandRoots(stripped).filter( + (r) => r !== 'shopt' && r !== 'set', + ); + if (roots.length > 0) { + return roots[0]; + } + return path.basename(req.command); +} + +export function verifySandboxOverrides( + allowOverrides: boolean, + policy: SandboxRequest['policy'], +) { + if (!allowOverrides) { + if ( + policy?.networkAccess || + policy?.allowedPaths?.length || + policy?.additionalPermissions?.network || + policy?.additionalPermissions?.fileSystem?.read?.length || + policy?.additionalPermissions?.fileSystem?.write?.length + ) { + throw new Error( + 'Sandbox request rejected: Cannot override readonly/network/filesystem restrictions in Plan mode.', + ); + } + } +} diff --git a/packages/core/src/sandbox/utils/fsUtils.ts b/packages/core/src/sandbox/utils/fsUtils.ts new file mode 100644 index 0000000000..f7fafd4c59 --- /dev/null +++ b/packages/core/src/sandbox/utils/fsUtils.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +export function isErrnoException(e: unknown): e is NodeJS.ErrnoException { + return e instanceof Error && 'code' in e; +} + +export function tryRealpath(p: string): string { + try { + return fs.realpathSync(p); + } catch (_e) { + if (isErrnoException(_e) && _e.code === 'ENOENT') { + const parentDir = path.dirname(p); + if (parentDir === p) { + return p; + } + return path.join(tryRealpath(parentDir), path.basename(p)); + } + throw _e; + } +} + +export function resolveGitWorktreePaths(workspacePath: string): { + worktreeGitDir?: string; + mainGitDir?: string; +} { + try { + const gitPath = path.join(workspacePath, '.git'); + const gitStat = fs.lstatSync(gitPath); + if (gitStat.isFile()) { + const gitContent = fs.readFileSync(gitPath, 'utf8'); + const match = gitContent.match(/^gitdir:\s+(.+)$/m); + if (match && match[1]) { + let worktreeGitDir = match[1].trim(); + if (!path.isAbsolute(worktreeGitDir)) { + worktreeGitDir = path.resolve(workspacePath, worktreeGitDir); + } + const resolvedWorktreeGitDir = tryRealpath(worktreeGitDir); + + // Security check: Verify the bidirectional link to prevent sandbox escape + let isValid = false; + try { + const backlinkPath = path.join(resolvedWorktreeGitDir, 'gitdir'); + const backlink = fs.readFileSync(backlinkPath, 'utf8').trim(); + // The backlink must resolve to the workspace's .git file + if (tryRealpath(backlink) === tryRealpath(gitPath)) { + isValid = true; + } + } catch (_e) { + // Fallback for submodules: check core.worktree in config + try { + const configPath = path.join(resolvedWorktreeGitDir, 'config'); + const config = fs.readFileSync(configPath, 'utf8'); + const match = config.match(/^\s*worktree\s*=\s*(.+)$/m); + if (match && match[1]) { + const worktreePath = path.resolve( + resolvedWorktreeGitDir, + match[1].trim(), + ); + if (tryRealpath(worktreePath) === tryRealpath(workspacePath)) { + isValid = true; + } + } + } catch (_e2) { + // Ignore + } + } + + if (!isValid) { + return {}; // Reject: valid worktrees/submodules must have a readable backlink + } + + const mainGitDir = tryRealpath( + path.dirname(path.dirname(resolvedWorktreeGitDir)), + ); + return { + worktreeGitDir: resolvedWorktreeGitDir, + mainGitDir: mainGitDir.endsWith('.git') ? mainGitDir : undefined, + }; + } + } + } catch (_e) { + // Ignore if .git doesn't exist, isn't readable, etc. + } + return {}; +} diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts index 8f9b9d617c..2c7e08a730 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts @@ -111,7 +111,7 @@ describe('WindowsSandboxManager', () => { }; await expect(planManager.prepareCommand(req)).rejects.toThrow( - 'Sandbox request rejected: Cannot override readonly/network restrictions in Plan mode.', + 'Sandbox request rejected: Cannot override readonly/network/filesystem restrictions in Plan mode.', ); }); diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts index 0a5d08637c..a213d7b619 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts @@ -31,6 +31,7 @@ import { isStrictlyApproved, } from './commandSafety.js'; import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js'; +import { verifySandboxOverrides } from '../utils/commandUtils.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -214,17 +215,7 @@ export class WindowsSandboxManager implements SandboxManager { const allowOverrides = this.options.modeConfig?.allowOverrides ?? true; // Reject override attempts in plan mode - if (!allowOverrides && req.policy?.additionalPermissions) { - const perms = req.policy.additionalPermissions; - if ( - perms.network || - (perms.fileSystem?.write && perms.fileSystem.write.length > 0) - ) { - throw new Error( - 'Sandbox request rejected: Cannot override readonly/network restrictions in Plan mode.', - ); - } - } + verifySandboxOverrides(allowOverrides, req.policy); // Fetch persistent approvals for this command const commandName = await getCommandName(req.command, req.args); diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index 0e282b0748..ea18e5857d 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -10,7 +10,7 @@ import path from 'node:path'; import { isKnownSafeCommand as isMacSafeCommand, isDangerousCommand as isMacDangerousCommand, -} from '../sandbox/macos/commandSafety.js'; +} from '../sandbox/utils/commandSafety.js'; import { isKnownSafeCommand as isWindowsSafeCommand, isDangerousCommand as isWindowsDangerousCommand, diff --git a/packages/core/src/services/sandboxManagerFactory.ts b/packages/core/src/services/sandboxManagerFactory.ts index bb8cea4752..6e09ab135f 100644 --- a/packages/core/src/services/sandboxManagerFactory.ts +++ b/packages/core/src/services/sandboxManagerFactory.ts @@ -42,7 +42,11 @@ export function createSandboxManager( policyManager, }); } else if (os.platform() === 'linux') { - return new LinuxSandboxManager({ workspace }); + return new LinuxSandboxManager({ + workspace, + modeConfig, + policyManager, + }); } else if (os.platform() === 'darwin') { return new MacOsSandboxManager({ workspace, From 9e7f52b8f543aae646ed4cea8d3bedfd7fc1f652 Mon Sep 17 00:00:00 2001 From: Chris Williams Date: Wed, 25 Mar 2026 19:57:23 -0700 Subject: [PATCH 32/49] Merge examples of use into quickstart documentation (#23319) --- docs/get-started/examples.md | 141 ----------------------------------- docs/get-started/index.md | 128 ++++++++++++++++++++++++++++++- docs/index.md | 2 - docs/redirects.json | 1 + docs/sidebar.json | 1 - 5 files changed, 128 insertions(+), 145 deletions(-) delete mode 100644 docs/get-started/examples.md diff --git a/docs/get-started/examples.md b/docs/get-started/examples.md deleted file mode 100644 index 18ebf865b4..0000000000 --- a/docs/get-started/examples.md +++ /dev/null @@ -1,141 +0,0 @@ -# Gemini CLI examples - -Gemini CLI helps you automate common engineering tasks by combining AI reasoning -with local system tools. This document provides examples of how to use the CLI -for file management, code analysis, and data transformation. - - -> [!NOTE] -> These examples demonstrate potential capabilities. Your actual -> results can vary based on the model used and your project environment. - -## Rename your photographs based on content - -You can use Gemini CLI to automate file management tasks that require visual -analysis. In this example, Gemini CLI renames images based on their actual -subject matter. - -Scenario: You have a folder containing the following files: - -```bash -photos/photo1.png -photos/photo2.png -photos/photo3.png -``` - -Give Gemini the following prompt: - -```cli -Rename the photos in my "photos" directory based on their contents. -``` - -Result: Gemini asks for permission to rename your files. - -Select **Allow once** and your files are renamed: - -```bash -photos/yellow_flowers.png -photos/antique_dresser.png -photos/green_android_robot.png -``` - -## Explain a repository by reading its code - -Gemini CLI is effective for rapid codebase exploration. The following example -shows how to ask Gemini CLI to fetch, analyze, and summarize a remote project. - -Scenario: You want to understand how a popular open-source utility works by -inspecting its code, not just its README. - -Give Gemini CLI the following prompt: - -```cli -Clone the 'chalk' repository from https://github.com/chalk/chalk, read its key source files, and explain how it works. -``` - -Result: Gemini performs a sequence of actions to answer your request. - -1. First, it asks for permission to run `git clone` to download the repository. -2. Next, it finds the important source files and asks for permission to read - them. -3. Finally, after analyzing the code, it provides a summary. - -Gemini CLI returns an explanation based on the actual source code: - -```markdown -The `chalk` library is a popular npm package for styling terminal output with -colors. After analyzing the source code, here's how it works: - -- **Core functionality:** The main file sets up a chainable API. Each color or - modifier (like `bold` or `italic`) is a getter that appends the corresponding - ANSI escape code to an internal stack. - -- **Chainable API:** When you call `chalk.red.bold('Hello')`, you are accessing - getters. The `red` getter adds the red color code, and the `bold` getter adds - the bold code. - -- **Output generation:** When the chain is treated as a string (e.g., in - `console.log`), a final `toString()` method is called. This method joins all - the stored ANSI codes, wraps them around the input string ('Hello'), and adds - a reset code at the end. This produces the final, styled string that the - terminal can render. -``` - -## Combine two spreadsheets into one spreadsheet - -Gemini CLI can process and transform data across multiple files. Use this -capability to merge reports or reformat data sets without manual copying. - -Scenario: You have two .csv files: `Revenue - 2023.csv` and -`Revenue - 2024.csv`. Each file contains monthly revenue figures. - -Give Gemini CLI the following prompt: - -```cli -Combine the two .csv files into a single .csv file, with each year a different column. -``` - -Result: Gemini CLI reads each file and then asks for permission to write a new -file. Provide your permission and Gemini CLI provides the combined data: - -```csv -Month,2023,2024 -January,0,1000 -February,0,1200 -March,0,2400 -April,900,500 -May,1000,800 -June,1000,900 -July,1200,1000 -August,1800,400 -September,2000,2000 -October,2400,3400 -November,3400,1800 -December,2100,9000 -``` - -## Run unit tests - -Gemini CLI can generate boilerplate code and tests based on your existing -implementation. This example demonstrates how to request code coverage for a -JavaScript component. - -Scenario: You've written a simple login page. You wish to write unit tests to -ensure that your login page has code coverage. - -Give Gemini CLI the following prompt: - -```cli -Write unit tests for Login.js. -``` - -Result: Gemini CLI asks for permission to write a new file and creates a test -for your login page. - -## Next steps - -- Follow the [File management](../cli/tutorials/file-management.md) guide to - start working with your codebase. -- Follow the [Quickstart](./index.md) to start your first session. -- See the [Cheatsheet](../cli/cli-reference.md) for a quick reference of - available commands. diff --git a/docs/get-started/index.md b/docs/get-started/index.md index 566ac6e9df..906998ab48 100644 --- a/docs/get-started/index.md +++ b/docs/get-started/index.md @@ -62,7 +62,133 @@ Once installed and authenticated, you can start using Gemini CLI by issuing commands and prompts in your terminal. Ask it to generate code, explain files, and more. -To explore the power of Gemini CLI, see [Gemini CLI examples](./examples.md). + +> [!NOTE] +> These examples demonstrate potential capabilities. Your actual +> results can vary based on the model used and your project environment. + +### Rename your photographs based on content + +You can use Gemini CLI to automate file management tasks that require visual +analysis. In this example, Gemini CLI renames images based on their actual +subject matter. + +Scenario: You have a folder containing the following files: + +```bash +photos/photo1.png +photos/photo2.png +photos/photo3.png +``` + +Give Gemini the following prompt: + +```cli +Rename the photos in my "photos" directory based on their contents. +``` + +Result: Gemini asks for permission to rename your files. + +Select **Allow once** and your files are renamed: + +```bash +photos/yellow_flowers.png +photos/antique_dresser.png +photos/green_android_robot.png +``` + +### Explain a repository by reading its code + +Gemini CLI is effective for rapid codebase exploration. The following example +shows how to ask Gemini CLI to fetch, analyze, and summarize a remote project. + +Scenario: You want to understand how a popular open-source utility works by +inspecting its code, not just its README. + +Give Gemini CLI the following prompt: + +```cli +Clone the 'chalk' repository from https://github.com/chalk/chalk, read its key source files, and explain how it works. +``` + +Result: Gemini performs a sequence of actions to answer your request. + +1. First, it asks for permission to run `git clone` to download the repository. +2. Next, it finds the important source files and asks for permission to read + them. +3. Finally, after analyzing the code, it provides a summary. + +Gemini CLI returns an explanation based on the actual source code: + +```markdown +The `chalk` library is a popular npm package for styling terminal output with +colors. After analyzing the source code, here's how it works: + +- **Core functionality:** The main file sets up a chainable API. Each color or + modifier (like `bold` or `italic`) is a getter that appends the corresponding + ANSI escape code to an internal stack. + +- **Chainable API:** When you call `chalk.red.bold('Hello')`, you are accessing + getters. The `red` getter adds the red color code, and the `bold` getter adds + the bold code. + +- **Output generation:** When the chain is treated as a string (e.g., in + `console.log`), a final `toString()` method is called. This method joins all + the stored ANSI codes, wraps them around the input string ('Hello'), and adds + a reset code at the end. This produces the final, styled string that the + terminal can render. +``` + +### Combine two spreadsheets into one spreadsheet + +Gemini CLI can process and transform data across multiple files. Use this +capability to merge reports or reformat data sets without manual copying. + +Scenario: You have two .csv files: `Revenue - 2023.csv` and +`Revenue - 2024.csv`. Each file contains monthly revenue figures. + +Give Gemini CLI the following prompt: + +```cli +Combine the two .csv files into a single .csv file, with each year a different column. +``` + +Result: Gemini CLI reads each file and then asks for permission to write a new +file. Provide your permission and Gemini CLI provides the combined data: + +```csv +Month,2023,2024 +January,0,1000 +February,0,1200 +March,0,2400 +April,900,500 +May,1000,800 +June,1000,900 +July,1200,1000 +August,1800,400 +September,2000,2000 +October,2400,3400 +November,3400,1800 +December,2100,9000 +``` + +### Run unit tests + +Gemini CLI can generate boilerplate code and tests based on your existing +implementation. This example demonstrates how to request code coverage for a +JavaScript component. + +Scenario: You've written a simple login page. You wish to write unit tests to +ensure that your login page has code coverage. + +Give Gemini CLI the following prompt: + +```cli +Write unit tests for Login.js. +``` + +Result: Gemini CLI asks for permission to write a new file and creates a test +for your login page. ## Check usage and quota diff --git a/docs/index.md b/docs/index.md index af1915bb8f..d1c1febf55 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,8 +19,6 @@ Jump in to Gemini CLI. on your system. - **[Authentication](./get-started/authentication.md):** Setup instructions for 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 commands and options. - **[Gemini 3 on Gemini CLI](./get-started/gemini-3.md):** Learn about Gemini 3 diff --git a/docs/redirects.json b/docs/redirects.json index 598f42cccf..db2dae4333 100644 --- a/docs/redirects.json +++ b/docs/redirects.json @@ -13,6 +13,7 @@ "/docs/faq": "/docs/resources/faq", "/docs/get-started/configuration": "/docs/reference/configuration", "/docs/get-started/configuration-v1": "/docs/reference/configuration", + "/docs/get-started/examples": "/docs/get-started/index", "/docs/index": "/docs", "/docs/quota-and-pricing": "/docs/resources/quota-and-pricing", "/docs/tos-privacy": "/docs/resources/tos-privacy", diff --git a/docs/sidebar.json b/docs/sidebar.json index 7198a0336b..e1ebd6ddd5 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -12,7 +12,6 @@ "label": "Authentication", "slug": "docs/get-started/authentication" }, - { "label": "Examples", "slug": "docs/get-started/examples" }, { "label": "CLI cheatsheet", "slug": "docs/cli/cli-reference" }, { "label": "Gemini 3 on Gemini CLI", From 49534209f29d6c9643eb15b8d041f4dadfc5b20a Mon Sep 17 00:00:00 2001 From: Sehoon Shon Date: Thu, 26 Mar 2026 08:18:57 -0400 Subject: [PATCH 33/49] fix(cli): prioritize primary name matches in slash command search (#23850) --- .../src/ui/hooks/useSlashCompletion.test.ts | 34 ++++++++++++++ .../cli/src/ui/hooks/useSlashCompletion.ts | 44 ++++++++++++++++--- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts index 575202ce98..0bcb3863ce 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts @@ -691,6 +691,40 @@ describe('useSlashCompletion', () => { }); unmount(); }); + + it('should rank primary name prefix matches higher than alias prefix matches', async () => { + const slashCommands = [ + createTestCommand({ + name: 'footer', + altNames: ['statusline'], + description: 'Configure footer', + }), + createTestCommand({ + name: 'stats', + altNames: ['usage'], + description: 'Check stats', + }), + ]; + + const { result, unmount } = await renderHook(() => + useTestHarnessForSlashCompletion( + true, + '/stat', + slashCommands, + mockCommandContext, + ), + ); + + await resolveMatch(); + + await waitFor(() => { + // 'stats' should be first because 'stat' is a prefix match on its name + // while 'footer' only matches 'stat' via its alias 'statusline' + expect(result.current.suggestions[0].label).toBe('stats'); + expect(result.current.suggestions[1].label).toBe('footer'); + }); + unmount(); + }); }); describe('Sub-Commands', () => { diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts index 4afa8e2241..7b06fdc1f4 100644 --- a/packages/cli/src/ui/hooks/useSlashCompletion.ts +++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts @@ -272,13 +272,45 @@ function useCommandSuggestions( } if (!signal.aborted) { - // Sort potentialSuggestions so that exact match (by name or altName) comes first + // Sort potentialSuggestions so that exact name/prefix match comes first, + // prioritizing primary name over altNames. + const lowerPartial = partial.toLowerCase(); const sortedSuggestions = [...potentialSuggestions].sort((a, b) => { - const aIsExact = matchesCommand(a, partial); - const bIsExact = matchesCommand(b, partial); - if (aIsExact && !bIsExact) return -1; - if (!aIsExact && bIsExact) return 1; - return 0; + // 1. Exact name match + const aNameExact = a.name.toLowerCase() === lowerPartial; + const bNameExact = b.name.toLowerCase() === lowerPartial; + if (aNameExact && !bNameExact) return -1; + if (!aNameExact && bNameExact) return 1; + + // 2. Exact altName match + const aAltExact = + a.altNames?.some((alt) => alt.toLowerCase() === lowerPartial) || + false; + const bAltExact = + b.altNames?.some((alt) => alt.toLowerCase() === lowerPartial) || + false; + if (aAltExact && !bAltExact) return -1; + if (!aAltExact && bAltExact) return 1; + + // 3. Prefix name match + const aNamePrefix = a.name.toLowerCase().startsWith(lowerPartial); + const bNamePrefix = b.name.toLowerCase().startsWith(lowerPartial); + if (aNamePrefix && !bNamePrefix) return -1; + if (!aNamePrefix && bNamePrefix) return 1; + + // 4. Prefix altName match + const aAltPrefix = + a.altNames?.some((alt) => + alt.toLowerCase().startsWith(lowerPartial), + ) || false; + const bAltPrefix = + b.altNames?.some((alt) => + alt.toLowerCase().startsWith(lowerPartial), + ) || false; + if (aAltPrefix && !bAltPrefix) return -1; + if (!aAltPrefix && bAltPrefix) return 1; + + return 0; // Maintain FZF score order for other matches }); const finalSuggestions = sortedSuggestions.map((cmd) => { From a3c1c659fd6c7ceeac86b53d7eb838f7979c8513 Mon Sep 17 00:00:00 2001 From: gemini-cli-robot Date: Thu, 26 Mar 2026 09:43:23 -0700 Subject: [PATCH 34/49] Changelog for v0.35.1 (#23840) Co-authored-by: gemini-cli-robot <224641728+gemini-cli-robot@users.noreply.github.com> Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com> --- docs/changelogs/latest.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/changelogs/latest.md b/docs/changelogs/latest.md index 8477a13e98..21b128ec30 100644 --- a/docs/changelogs/latest.md +++ b/docs/changelogs/latest.md @@ -1,6 +1,6 @@ -# Latest stable release: v0.35.0 +# Latest stable release: v0.35.1 -Released: March 24, 2026 +Released: March 26, 2026 For most users, our latest stable release is the recommended release. Install the latest stable version with: @@ -380,4 +380,4 @@ npm install -g @google/gemini-cli [#23585](https://github.com/google-gemini/gemini-cli/pull/23585) **Full Changelog**: -https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.0 +https://github.com/google-gemini/gemini-cli/compare/v0.34.0...v0.35.1 From 5755ec2dcfe4a9b03634844bf3bee8a044db26b9 Mon Sep 17 00:00:00 2001 From: Aditya Bijalwan Date: Thu, 26 Mar 2026 22:24:49 +0530 Subject: [PATCH 35/49] fix(browser): keep input blocker active across navigations (#22562) Co-authored-by: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com> --- .../src/agents/browser/browserManager.test.ts | 67 +++++++++++++++++-- .../core/src/agents/browser/browserManager.ts | 17 ++--- .../src/agents/browser/inputBlocker.test.ts | 66 +++++++++++++++++- .../core/src/agents/browser/inputBlocker.ts | 36 ++++++---- .../src/agents/browser/mcpToolWrapper.test.ts | 2 + .../core/src/agents/browser/mcpToolWrapper.ts | 6 +- 6 files changed, 163 insertions(+), 31 deletions(-) diff --git a/packages/core/src/agents/browser/browserManager.test.ts b/packages/core/src/agents/browser/browserManager.test.ts index c38457e4aa..a326164c43 100644 --- a/packages/core/src/agents/browser/browserManager.test.ts +++ b/packages/core/src/agents/browser/browserManager.test.ts @@ -9,6 +9,7 @@ import { BrowserManager } from './browserManager.js'; import { makeFakeConfig } from '../../test-utils/config.js'; import type { Config } from '../../config/config.js'; import { injectAutomationOverlay } from './automationOverlay.js'; +import { injectInputBlocker } from './inputBlocker.js'; import { coreEvents } from '../../utils/events.js'; // Mock the MCP SDK @@ -54,6 +55,13 @@ vi.mock('./automationOverlay.js', () => ({ injectAutomationOverlay: vi.fn().mockResolvedValue(undefined), })); +vi.mock('./inputBlocker.js', () => ({ + injectInputBlocker: vi.fn().mockResolvedValue(undefined), + removeInputBlocker: vi.fn().mockResolvedValue(undefined), + suspendInputBlocker: vi.fn().mockResolvedValue(undefined), + resumeInputBlocker: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('node:fs', async (importOriginal) => { const actual = await importOriginal(); return { @@ -78,6 +86,7 @@ describe('BrowserManager', () => { beforeEach(() => { vi.resetAllMocks(); vi.mocked(injectAutomationOverlay).mockClear(); + vi.mocked(injectInputBlocker).mockClear(); vi.spyOn(coreEvents, 'emitFeedback').mockImplementation(() => {}); // Re-establish consent mock after resetAllMocks @@ -692,21 +701,66 @@ describe('BrowserManager', () => { }); describe('overlay re-injection in callTool', () => { - it('should re-inject overlay after click in non-headless mode', async () => { + it('should re-inject overlay and input blocker after click in non-headless mode when input disabling is enabled', async () => { + // Enable input disabling in config + mockConfig = makeFakeConfig({ + agents: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { + headless: false, + disableUserInput: true, + }, + }, + }); + const manager = new BrowserManager(mockConfig); await manager.callTool('click', { uid: '1_2' }); expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined); + expect(injectInputBlocker).toHaveBeenCalledWith(manager, undefined); }); - it('should re-inject overlay after navigate_page in non-headless mode', async () => { + it('should re-inject overlay and input blocker after navigate_page in non-headless mode when input disabling is enabled', async () => { + mockConfig = makeFakeConfig({ + agents: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { + headless: false, + disableUserInput: true, + }, + }, + }); + const manager = new BrowserManager(mockConfig); await manager.callTool('navigate_page', { url: 'https://example.com' }); expect(injectAutomationOverlay).toHaveBeenCalledWith(manager, undefined); + expect(injectInputBlocker).toHaveBeenCalledWith(manager, undefined); }); - it('should re-inject overlay after click_at, new_page, press_key, handle_dialog', async () => { + it('should re-inject overlay and input blocker after click_at, new_page, press_key, handle_dialog when input disabling is enabled', async () => { + mockConfig = makeFakeConfig({ + agents: { + overrides: { + browser_agent: { + enabled: true, + }, + }, + browser: { + headless: false, + disableUserInput: true, + }, + }, + }); + const manager = new BrowserManager(mockConfig); for (const tool of [ 'click_at', @@ -715,12 +769,15 @@ describe('BrowserManager', () => { 'handle_dialog', ]) { vi.mocked(injectAutomationOverlay).mockClear(); + vi.mocked(injectInputBlocker).mockClear(); await manager.callTool(tool, {}); expect(injectAutomationOverlay).toHaveBeenCalledTimes(1); + expect(injectInputBlocker).toHaveBeenCalledTimes(1); + expect(injectInputBlocker).toHaveBeenCalledWith(manager, undefined); } }); - it('should NOT re-inject overlay after read-only tools', async () => { + it('should NOT re-inject overlay or input blocker after read-only tools', async () => { const manager = new BrowserManager(mockConfig); for (const tool of [ 'take_snapshot', @@ -729,8 +786,10 @@ describe('BrowserManager', () => { 'fill', ]) { vi.mocked(injectAutomationOverlay).mockClear(); + vi.mocked(injectInputBlocker).mockClear(); await manager.callTool(tool, {}); expect(injectAutomationOverlay).not.toHaveBeenCalled(); + expect(injectInputBlocker).not.toHaveBeenCalled(); } }); diff --git a/packages/core/src/agents/browser/browserManager.ts b/packages/core/src/agents/browser/browserManager.ts index 4eb9c2b19c..90de6b99fc 100644 --- a/packages/core/src/agents/browser/browserManager.ts +++ b/packages/core/src/agents/browser/browserManager.ts @@ -215,6 +215,10 @@ export class BrowserManager { // Re-inject the automation overlay and input blocker after tools that // can cause a full-page navigation. chrome-devtools-mcp emits no MCP // notifications, so callTool() is the only interception point. + // + // The input blocker injection is idempotent: the injected function + // reuses the existing DOM element when present and only recreates + // it when navigation has actually replaced the page DOM. if ( !result.isError && POTENTIALLY_NAVIGATING_TOOLS.has(toolName) && @@ -224,17 +228,8 @@ export class BrowserManager { if (this.shouldInjectOverlay) { await injectAutomationOverlay(this, signal); } - // Only re-inject the input blocker for tools that *reliably* - // replace the page DOM (navigate_page, new_page, select_page). - // click/click_at are handled by pointer-events suspend/resume - // in mcpToolWrapper — no full re-inject roundtrip needed. - // press_key/handle_dialog only sometimes navigate. - const reliableNavigation = - toolName === 'navigate_page' || - toolName === 'new_page' || - toolName === 'select_page'; - if (this.shouldDisableInput && reliableNavigation) { - await injectInputBlocker(this); + if (this.shouldDisableInput) { + await injectInputBlocker(this, signal); } } catch { // Never let overlay/blocker failures interrupt the tool result diff --git a/packages/core/src/agents/browser/inputBlocker.test.ts b/packages/core/src/agents/browser/inputBlocker.test.ts index 5d77aac079..abccac70c3 100644 --- a/packages/core/src/agents/browser/inputBlocker.test.ts +++ b/packages/core/src/agents/browser/inputBlocker.test.ts @@ -5,7 +5,12 @@ */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { injectInputBlocker, removeInputBlocker } from './inputBlocker.js'; +import { + injectInputBlocker, + removeInputBlocker, + suspendInputBlocker, + resumeInputBlocker, +} from './inputBlocker.js'; import type { BrowserManager } from './browserManager.js'; describe('inputBlocker', () => { @@ -28,6 +33,7 @@ describe('inputBlocker', () => { { function: expect.stringContaining('__gemini_input_blocker'), }, + undefined, ); }); @@ -77,6 +83,29 @@ describe('inputBlocker', () => { injectInputBlocker(mockBrowserManager), ).resolves.toBeUndefined(); }); + + it('should be safe to call multiple times (idempotent injection)', async () => { + await injectInputBlocker(mockBrowserManager); + await injectInputBlocker(mockBrowserManager); + + expect(mockBrowserManager.callTool).toHaveBeenCalledTimes(2); + expect(mockBrowserManager.callTool).toHaveBeenNthCalledWith( + 1, + 'evaluate_script', + expect.objectContaining({ + function: expect.stringContaining('__gemini_input_blocker'), + }), + undefined, + ); + expect(mockBrowserManager.callTool).toHaveBeenNthCalledWith( + 2, + 'evaluate_script', + expect.objectContaining({ + function: expect.stringContaining('__gemini_input_blocker'), + }), + undefined, + ); + }); }); describe('removeInputBlocker', () => { @@ -88,6 +117,7 @@ describe('inputBlocker', () => { { function: expect.stringContaining('__gemini_input_blocker'), }, + undefined, ); }); @@ -110,4 +140,38 @@ describe('inputBlocker', () => { ).resolves.toBeUndefined(); }); }); + + describe('suspendInputBlocker and resumeInputBlocker', () => { + it('should not throw when blocker element is missing', async () => { + // Simulate evaluate_script resolving successfully even if the DOM element is absent. + mockBrowserManager.callTool = vi.fn().mockResolvedValue({ + content: [{ type: 'text', text: 'Script ran on page and returned:' }], + }); + + await expect( + suspendInputBlocker(mockBrowserManager), + ).resolves.toBeUndefined(); + await expect( + resumeInputBlocker(mockBrowserManager), + ).resolves.toBeUndefined(); + + expect(mockBrowserManager.callTool).toHaveBeenCalledTimes(2); + expect(mockBrowserManager.callTool).toHaveBeenNthCalledWith( + 1, + 'evaluate_script', + expect.objectContaining({ + function: expect.stringContaining('__gemini_input_blocker'), + }), + undefined, + ); + expect(mockBrowserManager.callTool).toHaveBeenNthCalledWith( + 2, + 'evaluate_script', + expect.objectContaining({ + function: expect.stringContaining('__gemini_input_blocker'), + }), + undefined, + ); + }); + }); }); diff --git a/packages/core/src/agents/browser/inputBlocker.ts b/packages/core/src/agents/browser/inputBlocker.ts index ea6a797271..0d6b9610cf 100644 --- a/packages/core/src/agents/browser/inputBlocker.ts +++ b/packages/core/src/agents/browser/inputBlocker.ts @@ -198,11 +198,14 @@ const RESUME_BLOCKER_FUNCTION = `() => { */ export async function injectInputBlocker( browserManager: BrowserManager, + signal?: AbortSignal, ): Promise { try { - await browserManager.callTool('evaluate_script', { - function: INPUT_BLOCKER_FUNCTION, - }); + await browserManager.callTool( + 'evaluate_script', + { function: INPUT_BLOCKER_FUNCTION }, + signal, + ); debugLogger.log('Input blocker injected successfully'); } catch (error) { // Log but don't throw - input blocker is a UX enhancement, not critical functionality @@ -222,11 +225,14 @@ export async function injectInputBlocker( */ export async function removeInputBlocker( browserManager: BrowserManager, + signal?: AbortSignal, ): Promise { try { - await browserManager.callTool('evaluate_script', { - function: REMOVE_BLOCKER_FUNCTION, - }); + await browserManager.callTool( + 'evaluate_script', + { function: REMOVE_BLOCKER_FUNCTION }, + signal, + ); debugLogger.log('Input blocker removed successfully'); } catch (error) { // Log but don't throw - removal failure is not critical @@ -244,11 +250,14 @@ export async function removeInputBlocker( */ export async function suspendInputBlocker( browserManager: BrowserManager, + signal?: AbortSignal, ): Promise { try { - await browserManager.callTool('evaluate_script', { - function: SUSPEND_BLOCKER_FUNCTION, - }); + await browserManager.callTool( + 'evaluate_script', + { function: SUSPEND_BLOCKER_FUNCTION }, + signal, + ); } catch { // Non-critical — tool call will still attempt to proceed } @@ -260,11 +269,14 @@ export async function suspendInputBlocker( */ export async function resumeInputBlocker( browserManager: BrowserManager, + signal?: AbortSignal, ): Promise { try { - await browserManager.callTool('evaluate_script', { - function: RESUME_BLOCKER_FUNCTION, - }); + await browserManager.callTool( + 'evaluate_script', + { function: RESUME_BLOCKER_FUNCTION }, + signal, + ); } catch { // Non-critical } diff --git a/packages/core/src/agents/browser/mcpToolWrapper.test.ts b/packages/core/src/agents/browser/mcpToolWrapper.test.ts index 3a4d5cfe38..fa9aa228a5 100644 --- a/packages/core/src/agents/browser/mcpToolWrapper.test.ts +++ b/packages/core/src/agents/browser/mcpToolWrapper.test.ts @@ -224,6 +224,7 @@ describe('mcpToolWrapper', () => { expect.objectContaining({ function: expect.stringContaining('__gemini_input_blocker'), }), + expect.any(AbortSignal), ); // Second call: click @@ -241,6 +242,7 @@ describe('mcpToolWrapper', () => { expect.objectContaining({ function: expect.stringContaining('__gemini_input_blocker'), }), + expect.any(AbortSignal), ); }); diff --git a/packages/core/src/agents/browser/mcpToolWrapper.ts b/packages/core/src/agents/browser/mcpToolWrapper.ts index b57a7af7f0..cab493dff7 100644 --- a/packages/core/src/agents/browser/mcpToolWrapper.ts +++ b/packages/core/src/agents/browser/mcpToolWrapper.ts @@ -129,7 +129,7 @@ class McpToolInvocation extends BaseToolInvocation< // chrome-devtools-mcp's interactability checks pass. // Only toggles pointer-events CSS — no DOM change, no flicker. if (this.needsBlockerSuspend) { - await suspendInputBlocker(this.browserManager); + await suspendInputBlocker(this.browserManager, signal); } const result: McpToolCallResult = await this.browserManager.callTool( @@ -155,7 +155,7 @@ class McpToolInvocation extends BaseToolInvocation< // Resume input blocker after interactive tool completes. if (this.needsBlockerSuspend) { - await resumeInputBlocker(this.browserManager); + await resumeInputBlocker(this.browserManager, signal); } if (result.isError) { @@ -181,7 +181,7 @@ class McpToolInvocation extends BaseToolInvocation< // Resume on error path too so the blocker is always restored if (this.needsBlockerSuspend) { - await resumeInputBlocker(this.browserManager).catch(() => {}); + await resumeInputBlocker(this.browserManager, signal).catch(() => {}); } debugLogger.error(`MCP tool ${this.toolName} failed: ${errorMsg}`); From aa4d9316a91a68cc74ac327cfbe48fa490453757 Mon Sep 17 00:00:00 2001 From: Dev Randalpura Date: Thu, 26 Mar 2026 14:32:30 -0400 Subject: [PATCH 36/49] feat(core): new skill to look for duplicated code while reviewing PRs (#23704) --- .gemini/skills/review-duplication/SKILL.md | 69 ++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .gemini/skills/review-duplication/SKILL.md diff --git a/.gemini/skills/review-duplication/SKILL.md b/.gemini/skills/review-duplication/SKILL.md new file mode 100644 index 0000000000..966505bdf3 --- /dev/null +++ b/.gemini/skills/review-duplication/SKILL.md @@ -0,0 +1,69 @@ +--- +name: review-duplication +description: Use this skill during code reviews to proactively investigate the codebase for duplicated functionality, reinvented wheels, or failure to reuse existing project best practices and shared utilities. +--- + +# Review Duplication + +## Overview + +This skill provides a structured workflow for investigating a codebase during a code review to identify duplicated logic, reinvented utilities, and missed opportunities to reuse established patterns. By executing this workflow, you ensure that new code integrates seamlessly with the existing project architecture. + +## Workflow: Investigating for Duplication + +When reviewing code, perform the following steps before finalizing your review: + +### 1. Extract Core Logic +Analyze the new code to identify the core algorithms, utility functions, generic data structures, or UI components being introduced. Look beyond the specific business logic to see the underlying mechanics. + +### 2. Hypothesize Existing Locations & Trace Dependencies +Think about where this type of code *would* live if it already existed in the project. Provide absolute paths from the repo root to disambiguate. +- **Utilities:** `packages/core/src/utils/`, `packages/cli/src/utils/` +- **UI Components:** `packages/cli/src/ui/components/`, `packages/cli/src/ui/` +- **Services:** `packages/core/src/services/`, `packages/cli/src/services/` +- **Configuration:** `packages/core/src/config/`, `packages/cli/src/config/` +- **Core Logic:** Call out `packages/core/` if functionality does not appear React UI specific. + +**Trace Third-Party Dependencies:** If the PR introduces a new import for a utility library (e.g., `lodash.merge`, `date-fns`), trace how and where the project currently uses that library. There is likely an existing wrapper or shared utility. + +**Check Package Files:** Before flagging a custom implementation of a complex algorithm, check `package.json` to see if a standard library (like `lodash` or `uuid`) is already installed that provides this functionality. + +### 3. Investigate the Codebase (Sub-Agent Delegation) +Delegate the heavy lifting of codebase investigation to specialized sub-agents. They are optimized to perform deep searches and semantic mapping without bloating your session history. + +To ensure a comprehensive review, you MUST formulate highly specific objectives for the sub-agents, providing them with the "scents" you discovered in Step 1. + +- **Codebase Investigator:** Use the `codebase_investigator` as your primary researcher. When delegating, formulate an objective that asks specific, investigative questions about the codebase, explicitly including these search vectors: + - **Structural Similarity:** Ask if existing code uses the same underlying APIs (e.g., "Does any existing code use `Intl.DateTimeFormat` or `setTimeout` for similar purposes?"). + - **Naming Conventions:** Ask if there are existing symbols with similar naming patterns (e.g., "Are there existing symbols with naming patterns like `*Format*` or `*Debounce*`?"). + - **Comments & Documentation:** Ask if keywords from the PR's comments or JSDoc exist in describing similar behavior elsewhere. + - **Architectural Fit:** Ask where this type of logic is currently centralized (e.g., "Where is centralized date formatting logic located?"). + - **Refactoring Guidance:** Crucially, ask the sub-agent to explain *how* the new code could be refactored to use any existing logic it finds. +- **Generalist Agent:** Use the `generalist` for detailed, turn-intensive comparisons. For example: "Review the implementation of `MyNewComponent` in the PR and compare it semantically against all components in `packages/ui/src`. Are there any existing components that could be extended or used instead?" +- **Retain Fast Path for Simple Searches:** For extremely simple, unambiguous checks (e.g., "Does `package.json` include `lodash`?"), perform a direct search to save time. Default to delegation for any open-ended "investigations." + +### 4. Evaluate Best Practices +Check if the new code aligns with the project's established conventions. +- **Error Handling:** Does it use the project's standard error classes or logging mechanisms? +- **State Management:** Does it bypass established stores or contexts? +- **Styling:** Does it hardcode colors or spacing instead of using theme variables? +If the PR introduces a new pattern, compare it against the documented standards and explicitly confirm if an existing project pattern should have been used instead. + +### 5. Formulate Constructive Feedback +If you discover that the PR duplicates existing functionality or ignores a best practice: +- Provide a clear review comment. +- **Identify the Source:** Explicitly mention the absolute or project-relative file path and the specific symbol (function, component, class) that should be reused. +- **Implementation Guidance:** Provide a brief code snippet or a clear explanation showing **how** to integrate the existing code to fulfill the task's requirements. +- **Explain the Value:** Briefly explain why reusing the existing code is beneficial (e.g., maintainability, consistency, built-in edge case handling). + +Example comment: +> "It looks like this PR introduces a new `formatDate` utility. We already have a robust, tested `formatDate` function in `src/utils/dateHelpers.ts`. +> +> You can replace your implementation by importing it like this: +> ```typescript +> import { formatDate } from '../utils/dateHelpers'; +> +> // Then use it here: +> const displayDate = formatDate(userDate, 'MMM Do, YYYY'); +> ``` +> Reusing this ensures that the date formatting remains consistent with the rest of the application and handles timezone conversions correctly." From c888da5f737332bfbf04a0eef2ee2f008b1efff3 Mon Sep 17 00:00:00 2001 From: ruomeng Date: Thu, 26 Mar 2026 14:35:12 -0400 Subject: [PATCH 37/49] fix(core): replace hardcoded non-interactive ASK_USER denial with explicit policy rules (#23668) --- packages/cli/src/config/config.test.ts | 23 +++- packages/cli/src/config/config.ts | 2 +- .../config/policy-engine.integration.test.ts | 6 +- packages/cli/src/config/policy.ts | 8 +- .../src/config/workspace-policy-cli.test.ts | 14 ++ packages/core/src/policy/config.ts | 6 +- .../core/src/policy/policies/discovered.toml | 7 + .../src/policy/policies/non-interactive.toml | 7 + packages/core/src/policy/policies/plan.toml | 18 +++ packages/core/src/policy/policies/write.toml | 21 +++ packages/core/src/policy/policies/yolo.toml | 2 +- .../core/src/policy/policy-engine.test.ts | 125 +++++++++++++----- packages/core/src/policy/policy-engine.ts | 34 ++--- 13 files changed, 207 insertions(+), 66 deletions(-) create mode 100644 packages/core/src/policy/policies/non-interactive.toml diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index f312ddde4f..0d9fb8a9a0 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -143,12 +143,17 @@ vi.mock('@google/gemini-cli-core', async () => { respectGeminiIgnore: true, customIgnoreFilePaths: [], }, - createPolicyEngineConfig: vi.fn(async () => ({ - rules: [], - checkers: [], - defaultDecision: ServerConfig.PolicyDecision.ASK_USER, - approvalMode: ServerConfig.ApprovalMode.DEFAULT, - })), + createPolicyEngineConfig: vi.fn( + async (_settings, approvalMode, _workspacePoliciesDir, interactive) => ({ + rules: [], + checkers: [], + defaultDecision: interactive + ? ServerConfig.PolicyDecision.ASK_USER + : ServerConfig.PolicyDecision.DENY, + approvalMode: approvalMode ?? ServerConfig.ApprovalMode.DEFAULT, + nonInteractive: !interactive, + }), + ), getAdminErrorMessage: vi.fn( (_feature) => `YOLO mode is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli`, @@ -3460,6 +3465,8 @@ describe('Policy Engine Integration in loadCliConfig', () => { }), }), expect.anything(), + undefined, + expect.anything(), ); }); @@ -3481,6 +3488,8 @@ describe('Policy Engine Integration in loadCliConfig', () => { }), }), expect.anything(), + undefined, + expect.anything(), ); }); @@ -3504,6 +3513,8 @@ describe('Policy Engine Integration in loadCliConfig', () => { ], }), expect.anything(), + undefined, + expect.anything(), ); }); }); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index fa6d16fc72..af8c1ae0ac 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -792,8 +792,8 @@ export async function loadCliConfig( effectiveSettings, approvalMode, workspacePoliciesDir, + interactive, ); - policyEngineConfig.nonInteractive = !interactive; const defaultModel = PREVIEW_GEMINI_MODEL_AUTO; const specifiedModel = diff --git a/packages/cli/src/config/policy-engine.integration.test.ts b/packages/cli/src/config/policy-engine.integration.test.ts index 3b2a34ca69..edc06bfbf0 100644 --- a/packages/cli/src/config/policy-engine.integration.test.ts +++ b/packages/cli/src/config/policy-engine.integration.test.ts @@ -605,12 +605,12 @@ describe('Policy Engine Integration Tests', () => { it('should verify non-interactive mode transformation', async () => { const settings: Settings = {}; - const config = await createPolicyEngineConfig( + const engineConfig = await createPolicyEngineConfig( settings, ApprovalMode.DEFAULT, + undefined, + false, ); - // Enable non-interactive mode - const engineConfig = { ...config, nonInteractive: true }; const engine = new PolicyEngine(engineConfig); // ASK_USER should become DENY in non-interactive mode diff --git a/packages/cli/src/config/policy.ts b/packages/cli/src/config/policy.ts index 9837c2c355..317d2e848d 100644 --- a/packages/cli/src/config/policy.ts +++ b/packages/cli/src/config/policy.ts @@ -53,6 +53,7 @@ export async function createPolicyEngineConfig( settings: Settings, approvalMode: ApprovalMode, workspacePoliciesDir?: string, + interactive: boolean = true, ): Promise { // Explicitly construct PolicySettings from Settings to ensure type safety // and avoid accidental leakage of other settings properties. @@ -68,7 +69,12 @@ export async function createPolicyEngineConfig( settings.admin?.secureModeEnabled, }; - return createCorePolicyEngineConfig(policySettings, approvalMode); + return createCorePolicyEngineConfig( + policySettings, + approvalMode, + undefined, + interactive, + ); } export function createPolicyUpdater( diff --git a/packages/cli/src/config/workspace-policy-cli.test.ts b/packages/cli/src/config/workspace-policy-cli.test.ts index d0d98a5a31..bd9bcd0105 100644 --- a/packages/cli/src/config/workspace-policy-cli.test.ts +++ b/packages/cli/src/config/workspace-policy-cli.test.ts @@ -88,6 +88,8 @@ describe('Workspace-Level Policy CLI Integration', () => { ), }), expect.anything(), + undefined, + expect.anything(), ); }); @@ -107,6 +109,8 @@ describe('Workspace-Level Policy CLI Integration', () => { workspacePoliciesDir: undefined, }), expect.anything(), + undefined, + expect.anything(), ); }); @@ -131,6 +135,8 @@ describe('Workspace-Level Policy CLI Integration', () => { workspacePoliciesDir: undefined, }), expect.anything(), + undefined, + expect.anything(), ); }); @@ -163,6 +169,8 @@ describe('Workspace-Level Policy CLI Integration', () => { ), }), expect.anything(), + undefined, + expect.anything(), ); }); @@ -201,6 +209,8 @@ describe('Workspace-Level Policy CLI Integration', () => { ), }), expect.anything(), + undefined, + expect.anything(), ); }); @@ -237,6 +247,8 @@ describe('Workspace-Level Policy CLI Integration', () => { ), }), expect.anything(), + undefined, + expect.anything(), ); }); @@ -278,6 +290,8 @@ describe('Workspace-Level Policy CLI Integration', () => { workspacePoliciesDir: undefined, }), expect.anything(), + undefined, + expect.anything(), ); } finally { // Restore for other tests diff --git a/packages/core/src/policy/config.ts b/packages/core/src/policy/config.ts index f6107bf460..38106e7261 100644 --- a/packages/core/src/policy/config.ts +++ b/packages/core/src/policy/config.ts @@ -285,6 +285,7 @@ export async function createPolicyEngineConfig( settings: PolicySettings, approvalMode: ApprovalMode, defaultPoliciesDir?: string, + interactive: boolean = true, ): Promise { const systemPoliciesDir = path.resolve(Storage.getSystemPoliciesDir()); const userPoliciesDir = path.resolve(Storage.getUserPoliciesDir()); @@ -524,7 +525,10 @@ export async function createPolicyEngineConfig( return { rules, checkers, - defaultDecision: PolicyDecision.ASK_USER, + defaultDecision: interactive + ? PolicyDecision.ASK_USER + : PolicyDecision.DENY, + nonInteractive: !interactive, approvalMode, disableAlwaysAllow: settings.disableAlwaysAllow, }; diff --git a/packages/core/src/policy/policies/discovered.toml b/packages/core/src/policy/policies/discovered.toml index b343a1807f..41ebe8124e 100644 --- a/packages/core/src/policy/policies/discovered.toml +++ b/packages/core/src/policy/policies/discovered.toml @@ -6,3 +6,10 @@ toolName = "discovered_tool_*" decision = "ask_user" priority = 10 +interactive = true + +[[rule]] +toolName = "discovered_tool_*" +decision = "deny" +priority = 10 +interactive = false diff --git a/packages/core/src/policy/policies/non-interactive.toml b/packages/core/src/policy/policies/non-interactive.toml new file mode 100644 index 0000000000..04c41f6eb1 --- /dev/null +++ b/packages/core/src/policy/policies/non-interactive.toml @@ -0,0 +1,7 @@ +# Policy for non-interactive mode. +# ASK_USER is strictly forbidden here. +[[rule]] +toolName = "ask_user" +decision = "deny" +priority = 999 +interactive = false diff --git a/packages/core/src/policy/policies/plan.toml b/packages/core/src/policy/policies/plan.toml index 7627010662..b144f3c679 100644 --- a/packages/core/src/policy/policies/plan.toml +++ b/packages/core/src/policy/policies/plan.toml @@ -86,6 +86,16 @@ toolAnnotations = { readOnlyHint = true } decision = "ask_user" priority = 70 modes = ["plan"] +interactive = true + +[[rule]] +toolName = "*" +mcpName = "*" +toolAnnotations = { readOnlyHint = true } +decision = "deny" +priority = 70 +modes = ["plan"] +interactive = false [[rule]] toolName = [ @@ -108,6 +118,14 @@ toolName = ["ask_user", "save_memory"] decision = "ask_user" priority = 70 modes = ["plan"] +interactive = true + +[[rule]] +toolName = ["ask_user", "save_memory"] +decision = "deny" +priority = 70 +modes = ["plan"] +interactive = false # Allow write_file and replace for .md files in the plans directory (cross-platform) # We split this into two rules to avoid ReDoS checker issues with nested optional segments. diff --git a/packages/core/src/policy/policies/write.toml b/packages/core/src/policy/policies/write.toml index 527ac6f059..55ffd8c54f 100644 --- a/packages/core/src/policy/policies/write.toml +++ b/packages/core/src/policy/policies/write.toml @@ -31,6 +31,7 @@ toolName = "replace" decision = "ask_user" priority = 10 +interactive = true [[rule]] toolName = "replace" @@ -47,21 +48,25 @@ required_context = ["environment"] toolName = "save_memory" decision = "ask_user" priority = 10 +interactive = true [[rule]] toolName = "run_shell_command" decision = "ask_user" priority = 10 +interactive = true [[rule]] toolName = "write_file" decision = "ask_user" priority = 10 +interactive = true [[rule]] toolName = "activate_skill" decision = "ask_user" priority = 10 +interactive = true [[rule]] toolName = "write_file" @@ -84,3 +89,19 @@ modes = ["autoEdit"] toolName = "web_fetch" decision = "ask_user" priority = 10 +interactive = true + +# Headless Denial Rule (Priority 10) +# Ensures that tools that normally default to ASK_USER are denied in non-interactive mode. +[[rule]] +toolName = [ + "replace", + "save_memory", + "run_shell_command", + "write_file", + "activate_skill", + "web_fetch" +] +decision = "deny" +priority = 10 +interactive = false diff --git a/packages/core/src/policy/policies/yolo.toml b/packages/core/src/policy/policies/yolo.toml index 5e2a194d2e..b6a8fdea91 100644 --- a/packages/core/src/policy/policies/yolo.toml +++ b/packages/core/src/policy/policies/yolo.toml @@ -30,12 +30,12 @@ # Ask-user tool always requires user interaction, even in YOLO mode. # This ensures the model can gather user preferences/decisions when needed. -# Note: In non-interactive mode, this decision is converted to DENY by the policy engine. [[rule]] toolName = "ask_user" decision = "ask_user" priority = 999 modes = ["yolo"] +interactive = true # Plan mode transitions are blocked in YOLO mode to maintain state consistency # and because planning currently requires human interaction (plan approval), diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts index 137ca76aa1..95f754bc02 100644 --- a/packages/core/src/policy/policy-engine.test.ts +++ b/packages/core/src/policy/policy-engine.test.ts @@ -293,8 +293,22 @@ describe('PolicyEngine', () => { const config: PolicyEngineConfig = { nonInteractive: true, rules: [ - { toolName: 'interactive-tool', decision: PolicyDecision.ASK_USER }, + { + toolName: 'interactive-tool', + decision: PolicyDecision.ASK_USER, + interactive: true, + }, + { + toolName: 'interactive-tool', + decision: PolicyDecision.DENY, + interactive: false, + }, { toolName: 'allowed-tool', decision: PolicyDecision.ALLOW }, + { + toolName: 'ask_user', + decision: PolicyDecision.DENY, + interactive: false, + }, ], }; @@ -1258,6 +1272,51 @@ describe('PolicyEngine', () => { ).toBe(PolicyDecision.ALLOW); }); + it('should NOT automatically DENY redirected shell commands in non-interactive mode if rules permit it', async () => { + const toolName = 'run_shell_command'; + const command = 'ls > out.txt'; + + const rules: PolicyRule[] = [ + { + toolName, + decision: PolicyDecision.ALLOW, + allowRedirection: true, + }, + ]; + + engine = new PolicyEngine({ rules, nonInteractive: true }); + + expect( + (await engine.check({ name: toolName, args: { command } }, undefined)) + .decision, + ).toBe(PolicyDecision.ALLOW); + }); + + it('should respect DENY rules for redirected shell commands in non-interactive mode', async () => { + const toolName = 'run_shell_command'; + const command = 'ls > out.txt'; + + const rules: PolicyRule[] = [ + { + toolName, + decision: PolicyDecision.ASK_USER, + interactive: true, + }, + { + toolName, + decision: PolicyDecision.DENY, + interactive: false, + }, + ]; + + engine = new PolicyEngine({ rules, nonInteractive: true }); + + expect( + (await engine.check({ name: toolName, args: { command } }, undefined)) + .decision, + ).toBe(PolicyDecision.DENY); + }); + it('should NOT downgrade ALLOW to ASK_USER for quoted redirection chars', async () => { const rules: PolicyRule[] = [ { @@ -1423,21 +1482,25 @@ describe('PolicyEngine', () => { expect(result.decision).toBe(PolicyDecision.DENY); }); - it('should DENY redirected shell commands in non-interactive mode', async () => { + it('should respect explicit DENY rules for redirected shell commands in non-interactive mode', async () => { const config: PolicyEngineConfig = { nonInteractive: true, rules: [ { toolName: 'run_shell_command', decision: PolicyDecision.ALLOW, + interactive: true, + }, + { + toolName: 'run_shell_command', + decision: PolicyDecision.DENY, + interactive: false, }, ], }; engine = new PolicyEngine(config); - // Redirected command should be DENIED in non-interactive mode - // (Normally ASK_USER, but ASK_USER -> DENY in non-interactive) expect( ( await engine.check( @@ -2215,34 +2278,6 @@ describe('PolicyEngine', () => { const result = await engine.check({ name: 'tool' }, undefined); expect(result.decision).toBe(PolicyDecision.ASK_USER); }); - - it('should DENY if checker returns ASK_USER in non-interactive mode', async () => { - const rules: PolicyRule[] = [ - { toolName: 'tool', decision: PolicyDecision.ALLOW }, - ]; - const checkers: SafetyCheckerRule[] = [ - { - toolName: '*', - checker: { - type: 'in-process', - name: InProcessCheckerType.ALLOWED_PATH, - }, - }, - ]; - - engine = new PolicyEngine( - { rules, checkers, nonInteractive: true }, - mockCheckerRunner, - ); - - vi.mocked(mockCheckerRunner.runChecker).mockResolvedValue({ - decision: SafetyCheckDecision.ASK_USER, - reason: 'Suspicious path', - }); - - const result = await engine.check({ name: 'tool' }, undefined); - expect(result.decision).toBe(PolicyDecision.DENY); - }); }); describe('getExcludedTools', () => { @@ -2345,18 +2380,42 @@ describe('PolicyEngine', () => { expected: [], }, { - name: 'should NOT include ASK_USER tools even in non-interactive mode', + name: 'should include tools in exclusion list only if explicitly denied in non-interactive mode', rules: [ { toolName: 'tool1', decision: PolicyDecision.ASK_USER, modes: [ApprovalMode.DEFAULT], + interactive: true, + }, + { + toolName: 'tool1', + decision: PolicyDecision.DENY, + modes: [ApprovalMode.DEFAULT], + interactive: false, }, ], nonInteractive: true, allToolNames: ['tool1'], expected: ['tool1'], }, + { + name: 'should specifically exclude ask_user tool in non-interactive mode', + rules: [ + { + toolName: 'ask_user', + decision: PolicyDecision.DENY, + interactive: false, + }, + { + toolName: 'read_file', + decision: PolicyDecision.ALLOW, + }, + ], + nonInteractive: true, + allToolNames: ['ask_user', 'read_file'], + expected: ['ask_user'], + }, { name: 'should ignore rules with argsPattern', rules: [ diff --git a/packages/core/src/policy/policy-engine.ts b/packages/core/src/policy/policy-engine.ts index 18ab20bb14..c901116eb7 100644 --- a/packages/core/src/policy/policy-engine.ts +++ b/packages/core/src/policy/policy-engine.ts @@ -244,8 +244,10 @@ export class PolicyEngine { } } - this.defaultDecision = config.defaultDecision ?? PolicyDecision.ASK_USER; this.nonInteractive = config.nonInteractive ?? false; + this.defaultDecision = + config.defaultDecision ?? + (this.nonInteractive ? PolicyDecision.DENY : PolicyDecision.ASK_USER); this.disableAlwaysAllow = config.disableAlwaysAllow ?? false; this.checkerRunner = checkerRunner; this.approvalMode = config.approvalMode ?? ApprovalMode.DEFAULT; @@ -340,7 +342,7 @@ export class PolicyEngine { ): Promise { if (!command) { return { - decision: this.applyNonInteractiveMode(ruleDecision), + decision: ruleDecision, rule, }; } @@ -363,13 +365,13 @@ export class PolicyEngine { } debugLogger.debug( - `[PolicyEngine.check] Command parsing failed for: ${command}. Falling back to ASK_USER.`, + `[PolicyEngine.check] Command parsing failed for: ${command}. Falling back to ${this.defaultDecision}.`, ); - // Parsing logic failed, we can't trust it. Force ASK_USER (or DENY). + // Parsing logic failed, we can't trust it. Use default decision ASK_USER (or DENY in non-interactive). // We return the rule that matched so the evaluation loop terminates. return { - decision: this.applyNonInteractiveMode(PolicyDecision.ASK_USER), + decision: this.defaultDecision, rule, }; } @@ -466,7 +468,7 @@ export class PolicyEngine { } return { - decision: this.applyNonInteractiveMode(aggregateDecision), + decision: aggregateDecision, // If we stayed at ALLOW, we return the original rule (if any). // If we downgraded, we return the responsible rule (or undefined if implicit). rule: aggregateDecision === ruleDecision ? rule : responsibleRule, @@ -474,7 +476,7 @@ export class PolicyEngine { } return { - decision: this.applyNonInteractiveMode(ruleDecision), + decision: ruleDecision, rule, }; } @@ -597,7 +599,7 @@ export class PolicyEngine { break; } } else { - decision = this.applyNonInteractiveMode(rule.decision); + decision = rule.decision; matchedRule = rule; break; } @@ -641,7 +643,7 @@ export class PolicyEngine { decision = shellResult.decision; matchedRule = shellResult.rule; } else { - decision = this.applyNonInteractiveMode(this.defaultDecision); + decision = this.defaultDecision; } } @@ -697,7 +699,7 @@ export class PolicyEngine { } return { - decision: this.applyNonInteractiveMode(decision), + decision, rule: matchedRule, }; } @@ -866,7 +868,7 @@ export class PolicyEngine { continue; } else { // Unconditional rule for this tool - const decision = this.applyNonInteractiveMode(rule.decision); + const decision = rule.decision; staticallyExcluded = decision === PolicyDecision.DENY; matchFound = true; break; @@ -876,7 +878,7 @@ export class PolicyEngine { if (!matchFound) { // Fallback to default decision if no rule matches - const defaultDec = this.applyNonInteractiveMode(this.defaultDecision); + const defaultDec = this.defaultDecision; if (defaultDec === PolicyDecision.DENY) { staticallyExcluded = true; } @@ -889,12 +891,4 @@ export class PolicyEngine { return excludedTools; } - - private applyNonInteractiveMode(decision: PolicyDecision): PolicyDecision { - // In non-interactive mode, ASK_USER becomes DENY - if (this.nonInteractive && decision === PolicyDecision.ASK_USER) { - return PolicyDecision.DENY; - } - return decision; - } } From 9762bf296527737ab4eeaedb26f384c1f1f5139b Mon Sep 17 00:00:00 2001 From: Adib234 <30782825+Adib234@users.noreply.github.com> Date: Thu, 26 Mar 2026 14:45:03 -0400 Subject: [PATCH 38/49] fix(plan): after exiting plan mode switches model to a flash model (#23885) --- integration-tests/plan-mode.test.ts | 68 ++++++++++++++++++++++++++++- packages/core/src/config/config.ts | 1 + packages/core/src/core/client.ts | 4 ++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/integration-tests/plan-mode.test.ts b/integration-tests/plan-mode.test.ts index 977a754f1e..d8d297c460 100644 --- a/integration-tests/plan-mode.test.ts +++ b/integration-tests/plan-mode.test.ts @@ -4,8 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { TestRig, checkModelOutputContent } from './test-helper.js'; +import { GEMINI_DIR, TestRig, checkModelOutputContent } from './test-helper.js'; describe('Plan Mode', () => { let rig: TestRig; @@ -227,4 +229,68 @@ describe('Plan Mode', () => { `Expected write_file to succeed, but it failed with error: ${planWrite?.toolRequest.error}`, ).toBe(true); }); + it('should switch from a pro model to a flash model after exiting plan mode', async () => { + const plansDir = 'plans-folder'; + const planFilename = 'my-plan.md'; + + await rig.setup('should-switch-to-flash', { + settings: { + model: { + name: 'auto-gemini-2.5', + }, + experimental: { plan: true }, + tools: { + core: ['exit_plan_mode', 'run_shell_command'], + allowed: ['exit_plan_mode', 'run_shell_command'], + }, + general: { + defaultApprovalMode: 'plan', + plan: { + directory: plansDir, + }, + }, + }, + }); + + writeFileSync( + join(rig.homeDir!, GEMINI_DIR, 'state.json'), + JSON.stringify({ terminalSetupPromptShown: true }, null, 2), + ); + + const fullPlansDir = join(rig.testDir!, plansDir); + mkdirSync(fullPlansDir, { recursive: true }); + writeFileSync(join(fullPlansDir, planFilename), 'Execute echo hello'); + + await rig.run({ + approvalMode: 'plan', + stdin: `Exit plan mode using ${planFilename} and then run a shell command \`echo hello\`.`, + }); + + const exitCallFound = await rig.waitForToolCall('exit_plan_mode'); + expect(exitCallFound, 'Expected exit_plan_mode to be called').toBe(true); + + const shellCallFound = await rig.waitForToolCall('run_shell_command'); + expect(shellCallFound, 'Expected run_shell_command to be called').toBe( + true, + ); + + const apiRequests = rig.readAllApiRequest(); + const modelNames = apiRequests.map((r) => r.attributes?.model || 'unknown'); + + const proRequests = apiRequests.filter((r) => + r.attributes?.model?.includes('pro'), + ); + const flashRequests = apiRequests.filter((r) => + r.attributes?.model?.includes('flash'), + ); + + expect( + proRequests.length, + `Expected at least one Pro request. Models used: ${modelNames.join(', ')}`, + ).toBeGreaterThanOrEqual(1); + expect( + flashRequests.length, + `Expected at least one Flash request after mode switch. Models used: ${modelNames.join(', ')}`, + ).toBeGreaterThanOrEqual(1); + }); }); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e727881a04..70ac02e22f 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -2413,6 +2413,7 @@ export class Config implements McpContext, AgentLoopContext { if (isPlanModeTransition || isYoloModeTransition) { if (this._geminiClient?.isInitialized()) { + this._geminiClient.clearCurrentSequenceModel(); this._geminiClient.setTools().catch((err) => { debugLogger.error('Failed to update tools', err); }); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index b37d4ad91c..8922c977f2 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -132,6 +132,10 @@ export class GeminiClient { this.updateSystemInstruction(); }; + clearCurrentSequenceModel(): void { + this.currentSequenceModel = null; + } + // Hook state to deduplicate BeforeAgent calls and track response for // AfterAgent private hookStateMap = new Map< From 1d2fbbf9c32e2ff9f36b6e89d9f1bb8abee8fbc0 Mon Sep 17 00:00:00 2001 From: matt korwel Date: Thu, 26 Mar 2026 12:01:37 -0700 Subject: [PATCH 39/49] feat(gcp): add development worker infrastructure (#23814) --- .gcp/Dockerfile.development | 89 ++++++++++++++++++++++++ .gcp/Dockerfile.development.dockerignore | 10 +++ .gcp/development-worker.yml | 58 +++++++++++++++ 3 files changed, 157 insertions(+) create mode 100644 .gcp/Dockerfile.development create mode 100644 .gcp/Dockerfile.development.dockerignore create mode 100644 .gcp/development-worker.yml diff --git a/.gcp/Dockerfile.development b/.gcp/Dockerfile.development new file mode 100644 index 0000000000..fb572c3783 --- /dev/null +++ b/.gcp/Dockerfile.development @@ -0,0 +1,89 @@ +# --- STAGE 1: Base Runtime --- +FROM docker.io/library/node:20-slim AS base + +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + python3-pip \ + python3-venv \ + curl \ + dnsutils \ + less \ + jq \ + ca-certificates \ + git \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# --- STAGE 2: Builder (Compile Main) --- +FROM base AS builder +WORKDIR /build +COPY . . +RUN npm ci --ignore-scripts +RUN npm run bundle +# Run the official release preparation script to move the bundle and assets into packages/cli +RUN node scripts/prepare-npm-release.js + +# --- STAGE 3: Development Environment --- +FROM base AS development + +WORKDIR /home/node/dev/main + +# Set up npm global package folder +RUN mkdir -p /usr/local/share/npm-global \ + && chown -R node:node /usr/local/share/npm-global +ENV NPM_CONFIG_PREFIX=/usr/local/share/npm-global +ENV PATH=$PATH:/usr/local/share/npm-global/bin + +# Copy package.json to extract versions for global tools +COPY package.json /tmp/package.json + +# Install Build Tools, Global Dev Tools (pinned), and Linters +ARG ACTIONLINT_VER=1.7.7 +ARG SHELLCHECK_VER=0.11.0 +ARG YAMLLINT_VER=1.35.1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + make \ + g++ \ + gh \ + git \ + unzip \ + rsync \ + ripgrep \ + procps \ + psmisc \ + lsof \ + socat \ + tmux \ + docker.io \ + build-essential \ + libsecret-1-dev \ + libkrb5-dev \ + file \ + && curl -sSLo /tmp/actionlint.tar.gz https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VER}/actionlint_${ACTIONLINT_VER}_linux_amd64.tar.gz \ + && tar -xzf /tmp/actionlint.tar.gz -C /usr/local/bin actionlint \ + && curl -sSLo /tmp/shellcheck.tar.xz https://github.com/koalaman/shellcheck/releases/download/v${SHELLCHECK_VER}/shellcheck-v${SHELLCHECK_VER}.linux.x86_64.tar.xz \ + && tar -xf /tmp/shellcheck.tar.xz -C /usr/local/bin --strip-components=1 shellcheck-v${SHELLCHECK_VER}/shellcheck \ + && pip3 install --break-system-packages yamllint==${YAMLLINT_VER} \ + && export TSX_VER=$(node -p "require('/tmp/package.json').devDependencies.tsx") \ + && export VITEST_VER=$(node -p "require('/tmp/package.json').devDependencies.vitest") \ + && export PRETTIER_VER=$(node -p "require('/tmp/package.json').devDependencies.prettier") \ + && export ESLINT_VER=$(node -p "require('/tmp/package.json').devDependencies.eslint") \ + && export CROSS_ENV_VER=$(node -p "require('/tmp/package.json').devDependencies['cross-env']") \ + && npm install -g tsx@$TSX_VER vitest@$VITEST_VER prettier@$PRETTIER_VER eslint@$ESLINT_VER cross-env@$CROSS_ENV_VER typescript@5.3.3 \ + && npm install -g @google/gemini-cli@nightly && mv /usr/local/share/npm-global/bin/gemini /usr/local/share/npm-global/bin/g-nightly \ + && npm install -g @google/gemini-cli@preview && mv /usr/local/share/npm-global/bin/gemini /usr/local/share/npm-global/bin/g-preview \ + && npm install -g @google/gemini-cli@latest && mv /usr/local/share/npm-global/bin/gemini /usr/local/share/npm-global/bin/g-stable \ + && apt-get purge -y build-essential libsecret-1-dev libkrb5-dev \ + && apt-get autoremove -y \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /root/.npm + +# Copy the bundled CLI package to a permanent location and install it +# We MUST not delete this source folder as 'npm install -g ' +# often symlinks to it for local folder installs. +COPY --from=builder /build/packages/cli /usr/local/lib/gemini-cli +RUN npm install -g /usr/local/lib/gemini-cli + +USER node +CMD ["/bin/bash"] diff --git a/.gcp/Dockerfile.development.dockerignore b/.gcp/Dockerfile.development.dockerignore new file mode 100644 index 0000000000..3e48beb792 --- /dev/null +++ b/.gcp/Dockerfile.development.dockerignore @@ -0,0 +1,10 @@ +node_modules +.git +.gemini/workspaces +dist +!packages/*/dist/*.tgz +bundle +out +*.log +.env +.DS_Store diff --git a/.gcp/development-worker.yml b/.gcp/development-worker.yml new file mode 100644 index 0000000000..1ef1346eda --- /dev/null +++ b/.gcp/development-worker.yml @@ -0,0 +1,58 @@ +substitutions: + _IMAGE_NAME: 'development' + _ARTIFACT_REGISTRY_REPO: 'us-docker.pkg.dev/gemini-code-dev/gemini-cli' + +steps: + # Step 1: Install root dependencies + - name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder' + id: 'Install Dependencies' + entrypoint: 'npm' + args: ['install'] + + # Step 2: Authenticate for Docker + - name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder' + id: 'Authenticate docker' + entrypoint: 'npm' + args: ['run', 'auth'] + + # Step 3: Build workspace packages + - name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder' + id: 'Build packages' + entrypoint: 'npm' + args: ['run', 'build:packages'] + + # Step 4: Build Development Image + - name: 'us-west1-docker.pkg.dev/gemini-code-dev/gemini-code-containers/gemini-code-builder' + id: 'Build Development Image' + entrypoint: 'bash' + env: + - 'RAW_BRANCH_VALUE=${BRANCH_NAME}' + args: + - '-c' + - |- + IMAGE_BASE="${_ARTIFACT_REGISTRY_REPO}/${_IMAGE_NAME}" + + # Determine the primary tag (branch name or 'latest' for main) + # Use $$ for shell variables to avoid Cloud Build attempting premature substitution + RAW_BRANCH="$$RAW_BRANCH_VALUE" + if [ "$${RAW_BRANCH}" == "main" ]; then + TAG_PRIMARY="latest" + else + TAG_PRIMARY=$$(echo "$${RAW_BRANCH}" | sed 's/[^a-zA-Z0-9]/-/g' | tr '[:upper:]' '[:lower:]') + fi + + # Use SHORT_SHA if available (Cloud Build) or fallback to latest-dev + TAG_SHA="$${SHORT_SHA:-latest-dev}" + + echo "📦 Building Development Image for: $${RAW_BRANCH} -> $${TAG_PRIMARY} ($${TAG_SHA})" + + docker build -f .gcp/Dockerfile.development \ + -t "$${IMAGE_BASE}:$${TAG_SHA}" \ + -t "$${IMAGE_BASE}:$${TAG_PRIMARY}" . + + docker push "$${IMAGE_BASE}:$${TAG_SHA}" + docker push "$${IMAGE_BASE}:$${TAG_PRIMARY}" + +options: + defaultLogsBucketBehavior: 'REGIONAL_USER_OWNED_BUCKET' + dynamicSubstitutions: true From bf03543bf6b9b5d64c106d7fc69c5f8cac796663 Mon Sep 17 00:00:00 2001 From: Keith Schaab Date: Thu, 26 Mar 2026 19:10:18 +0000 Subject: [PATCH 40/49] fix(a2a-server): A2A server should execute ask policies in interactive mode (#23831) --- packages/a2a-server/src/config/config.test.ts | 22 +++++++++++++++---- packages/a2a-server/src/config/config.ts | 2 +- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/a2a-server/src/config/config.test.ts b/packages/a2a-server/src/config/config.test.ts index 007f1d5f06..1c553d7539 100644 --- a/packages/a2a-server/src/config/config.test.ts +++ b/packages/a2a-server/src/config/config.test.ts @@ -352,23 +352,37 @@ describe('loadConfig', () => { }); describe('interactivity', () => { - it('should set interactive true when not headless', async () => { + it('should always set interactive true', async () => { + vi.mocked(isHeadlessMode).mockReturnValue(true); + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + interactive: true, + }), + ); + vi.mocked(isHeadlessMode).mockReturnValue(false); await loadConfig(mockSettings, mockExtensionLoader, taskId); expect(Config).toHaveBeenCalledWith( expect.objectContaining({ interactive: true, - enableInteractiveShell: true, }), ); }); - it('should set interactive false when headless', async () => { + it('should set enableInteractiveShell based on headless mode', async () => { + vi.mocked(isHeadlessMode).mockReturnValue(false); + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + enableInteractiveShell: true, + }), + ); + vi.mocked(isHeadlessMode).mockReturnValue(true); await loadConfig(mockSettings, mockExtensionLoader, taskId); expect(Config).toHaveBeenCalledWith( expect.objectContaining({ - interactive: false, enableInteractiveShell: false, }), ); diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts index c3561629b6..cd4f5df25f 100644 --- a/packages/a2a-server/src/config/config.ts +++ b/packages/a2a-server/src/config/config.ts @@ -125,7 +125,7 @@ export async function loadConfig( trustedFolder: true, extensionLoader, checkpointing, - interactive: !isHeadlessMode(), + interactive: true, enableInteractiveShell: !isHeadlessMode(), ptyInfo: 'auto', enableAgents: settings.experimental?.enableAgents ?? true, From c92ae8a359fe7746cc180d15973e962535125987 Mon Sep 17 00:00:00 2001 From: Sehoon Shon Date: Thu, 26 Mar 2026 15:24:06 -0400 Subject: [PATCH 41/49] feat(core): define TrajectoryProvider interface (#23050) --- packages/core/src/config/config.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 70ac02e22f..d8898e1e3b 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -10,6 +10,8 @@ import { SandboxPolicyManager } from '../policy/sandboxPolicyManager.js'; import { inspect } from 'node:util'; import process from 'node:process'; import { z } from 'zod'; +import type { ConversationRecord } from '../services/chatRecordingService.js'; +export type { ConversationRecord }; import { AuthType, createContentGenerator, @@ -231,6 +233,25 @@ export interface ResolvedExtensionSetting { source?: string; } +export interface TrajectoryProvider { + /** Prefix used to identify sessions from this provider (e.g., 'ext:') */ + prefix: string; + /** Optional display name for UI Tabs */ + displayName?: string; + /** Return an array of conversational tags/ids */ + listSessions(workspaceUri?: string): Promise< + Array<{ + id: string; + mtime: string; + name?: string; + displayName?: string; + messageCount?: number; + }> + >; + /** Load a single conversation payload */ + loadSession(id: string): Promise; +} + export interface AgentRunConfig { maxTimeMinutes?: number; maxTurns?: number; @@ -386,6 +407,8 @@ export interface GeminiCLIExtension { * Used to migrate an extension to a new repository source. */ migratedTo?: string; + /** Loaded JS module for trajectory decoding */ + trajectoryProviderModule?: TrajectoryProvider; } export interface ExtensionInstallMetadata { From 1d230dbfbfb27a1aff1d42a6cc716a81b75d5ec7 Mon Sep 17 00:00:00 2001 From: Jenna Inouye Date: Thu, 26 Mar 2026 12:29:37 -0700 Subject: [PATCH 42/49] Docs: Update quotas and pricing (#23835) --- docs/resources/quota-and-pricing.md | 32 +++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/docs/resources/quota-and-pricing.md b/docs/resources/quota-and-pricing.md index 16d6b407b8..18beb7c761 100644 --- a/docs/resources/quota-and-pricing.md +++ b/docs/resources/quota-and-pricing.md @@ -12,6 +12,21 @@ quota for your needs, see the [Plans page](https://geminicli.com/plans/). This article outlines the specific quotas and pricing applicable to Gemini CLI when using different authentication methods. +The following table summarizes the available quotas and their respective limits: + +| Authentication method | Tier / Subscription | Maximum requests per user per day | +| :-------------------- | :------------------------------ | :-------------------------------- | +| **Google account** | Gemini Code Assist (Individual) | 1,000 requests | +| | Google AI Pro | 1,500 requests | +| | Google AI Ultra | 2,000 requests | +| **Gemini API key** | Free tier (Unpaid) | 250 requests | +| | Pay-as-you-go (Paid) | Varies | +| **Vertex AI** | Express mode (Free) | Varies | +| | Pay-as-you-go (Paid) | Varies | +| **Google Workspace** | Code Assist Standard | 1,500 requests | +| | Code Assist Enterprise | 2,000 requests | +| | Workspace AI Ultra | 2,000 requests | + Generally, there are three categories to choose from: - Free Usage: Ideal for experimentation and light use. @@ -20,6 +35,9 @@ Generally, there are three categories to choose from: - Pay-As-You-Go: The most flexible option for professional use, long-running tasks, or when you need full control over your usage. +Requests are limited per user per minute and are subject to the availability of +the service in times of high demand. + ## Free usage Access to Gemini CLI begins with a generous free tier, perfect for @@ -33,8 +51,7 @@ authorization type. For users who authenticate by using their Google account to access Gemini Code Assist for individuals. This includes: -- 1000 model requests / user / day -- 60 model requests / user / minute +- 1000 maximum model requests / user / day - Model requests will be made across the Gemini model family as determined by Gemini CLI. @@ -46,8 +63,7 @@ Learn more at If you are using a Gemini API key, you can also benefit from a free tier. This includes: -- 250 model requests / user / day -- 10 model requests / user / minute +- 250 maximum model requests / user / day - Model requests to Flash model only. Learn more at @@ -59,7 +75,7 @@ Vertex AI offers an Express Mode without the need to enable billing. This includes: - 90 days before you need to enable billing. -- Quotas and models are variable and specific to your account. +- Quotas and models are specific to your account and their limits vary. Learn more at [Vertex AI Express Mode Limits](https://cloud.google.com/vertex-ai/generative-ai/docs/start/express-mode/overview#quotas). @@ -112,11 +128,9 @@ Standard/Plus and AI Expanded, are not supported._ This includes the following request limits: - Gemini Code Assist Standard edition: - - 1500 model requests / user / day - - 120 model requests / user / minute + - 1500 maximum model requests / user / day - Gemini Code Assist Enterprise edition: - - 2000 model requests / user / day - - 120 model requests / user / minute + - 2000 maximum model requests / user / day - Model requests will be made across the Gemini model family as determined by Gemini CLI. From d33170931c3be6384b10f68c7a151767ead055b1 Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Thu, 26 Mar 2026 13:04:44 -0700 Subject: [PATCH 43/49] fix(core): allow disabling environment variable redaction (#23927) --- .../src/sandbox/macos/MacOsSandboxManager.test.ts | 5 ++++- .../src/services/environmentSanitization.test.ts | 8 ++++---- .../core/src/services/environmentSanitization.ts | 5 ++++- .../src/services/sandboxManager.integration.test.ts | 13 ++++++++++++- packages/core/src/services/sandboxManager.test.ts | 13 ++++++++++--- 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts index 3f23a22553..d528223b7e 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.test.ts @@ -112,7 +112,10 @@ describe('MacOsSandboxManager', () => { SAFE_VAR: '1', GITHUB_TOKEN: 'sensitive', }, - policy: mockPolicy, + policy: { + ...mockPolicy, + sanitizationConfig: { enableEnvironmentVariableRedaction: true }, + }, }); expect(result.env['SAFE_VAR']).toBe('1'); diff --git a/packages/core/src/services/environmentSanitization.test.ts b/packages/core/src/services/environmentSanitization.test.ts index a7889ef0c2..e36f879f06 100644 --- a/packages/core/src/services/environmentSanitization.test.ts +++ b/packages/core/src/services/environmentSanitization.test.ts @@ -375,9 +375,9 @@ describe('sanitizeEnvironment', () => { }); describe('getSecureSanitizationConfig', () => { - it('should enable environment variable redaction by default', () => { + it('should default enableEnvironmentVariableRedaction to false', () => { const config = getSecureSanitizationConfig(); - expect(config.enableEnvironmentVariableRedaction).toBe(true); + expect(config.enableEnvironmentVariableRedaction).toBe(false); }); it('should merge allowed and blocked variables from base and requested configs', () => { @@ -440,13 +440,13 @@ describe('getSecureSanitizationConfig', () => { expect(config.blockedEnvironmentVariables).toEqual(['BLOCKED_VAR']); }); - it('should force enableEnvironmentVariableRedaction to true even if requested false', () => { + it('should respect requested enableEnvironmentVariableRedaction value', () => { const requestedConfig = { enableEnvironmentVariableRedaction: false, }; const config = getSecureSanitizationConfig(requestedConfig); - expect(config.enableEnvironmentVariableRedaction).toBe(true); + expect(config.enableEnvironmentVariableRedaction).toBe(false); }); }); diff --git a/packages/core/src/services/environmentSanitization.ts b/packages/core/src/services/environmentSanitization.ts index f3c5628607..eb95a91ca8 100644 --- a/packages/core/src/services/environmentSanitization.ts +++ b/packages/core/src/services/environmentSanitization.ts @@ -230,6 +230,9 @@ export function getSecureSanitizationConfig( allowedEnvironmentVariables: [...new Set(allowed)], blockedEnvironmentVariables: [...new Set(blocked)], // Redaction must be enabled for secure configurations - enableEnvironmentVariableRedaction: true, + enableEnvironmentVariableRedaction: + requestedConfig.enableEnvironmentVariableRedaction ?? + baseConfig?.enableEnvironmentVariableRedaction ?? + false, }; } diff --git a/packages/core/src/services/sandboxManager.integration.test.ts b/packages/core/src/services/sandboxManager.integration.test.ts index c4bc2f1cc5..e1954e9a5b 100644 --- a/packages/core/src/services/sandboxManager.integration.test.ts +++ b/packages/core/src/services/sandboxManager.integration.test.ts @@ -108,7 +108,18 @@ function ensureSandboxAvailable(): boolean { if (platform === 'darwin') { if (fs.existsSync('/usr/bin/sandbox-exec')) { - return true; + try { + execSync('sandbox-exec -p "(version 1)(allow default)" echo test', { + stdio: 'ignore', + }); + return true; + } catch { + // eslint-disable-next-line no-console + console.warn( + 'sandbox-exec is present but cannot be used (likely running inside a sandbox already). Skipping sandbox tests.', + ); + return false; + } } throw new Error( 'Sandboxing tests on macOS require /usr/bin/sandbox-exec to be present.', diff --git a/packages/core/src/services/sandboxManager.test.ts b/packages/core/src/services/sandboxManager.test.ts index 1f3cfa089e..a677c790b1 100644 --- a/packages/core/src/services/sandboxManager.test.ts +++ b/packages/core/src/services/sandboxManager.test.ts @@ -148,6 +148,11 @@ describe('SandboxManager', () => { MY_SECRET: 'super-secret', SAFE_VAR: 'is-safe', }, + policy: { + sanitizationConfig: { + enableEnvironmentVariableRedaction: true, + }, + }, }; const result = await sandboxManager.prepareCommand(req); @@ -158,7 +163,7 @@ describe('SandboxManager', () => { expect(result.env['MY_SECRET']).toBeUndefined(); }); - it('should NOT allow disabling environment variable redaction if requested in config (vulnerability fix)', async () => { + it('should allow disabling environment variable redaction if requested in config', async () => { const req = { command: 'echo', args: ['hello'], @@ -175,8 +180,8 @@ describe('SandboxManager', () => { const result = await sandboxManager.prepareCommand(req); - // API_KEY should be redacted because SandboxManager forces redaction and API_KEY matches NEVER_ALLOWED_NAME_PATTERNS - expect(result.env['API_KEY']).toBeUndefined(); + // API_KEY should be preserved because redaction was explicitly disabled + expect(result.env['API_KEY']).toBe('sensitive-key'); }); it('should respect allowedEnvironmentVariables in config but filter sensitive ones', async () => { @@ -191,6 +196,7 @@ describe('SandboxManager', () => { policy: { sanitizationConfig: { allowedEnvironmentVariables: ['MY_SAFE_VAR', 'MY_TOKEN'], + enableEnvironmentVariableRedaction: true, }, }, }; @@ -214,6 +220,7 @@ describe('SandboxManager', () => { policy: { sanitizationConfig: { blockedEnvironmentVariables: ['BLOCKED_VAR'], + enableEnvironmentVariableRedaction: true, }, }, }; From 84f1c19265db4ac221778805f72fd7c47c6642e5 Mon Sep 17 00:00:00 2001 From: Gen Zhang Date: Thu, 26 Mar 2026 20:10:49 +0000 Subject: [PATCH 44/49] feat(cli): enable notifications cross-platform via terminal bell fallback (#21618) Co-authored-by: Sandy Tao --- docs/cli/notifications.md | 10 +++++----- docs/cli/settings.md | 2 +- docs/reference/configuration.md | 2 +- packages/cli/src/config/settingsSchema.ts | 2 +- ...ttings-list-with-visual-indicators.snap.svg | 2 +- ...ibility-settings-enabled-correctly.snap.svg | 2 +- ...oolean-settings-disabled-correctly.snap.svg | 2 +- ...uld-render-default-state-correctly.snap.svg | 2 +- ...ring-settings-configured-correctly.snap.svg | 2 +- ...ocused-on-scope-selector-correctly.snap.svg | 2 +- ...lean-and-number-settings-correctly.snap.svg | 2 +- ...ls-and-security-settings-correctly.snap.svg | 2 +- ...boolean-settings-enabled-correctly.snap.svg | 2 +- .../__snapshots__/SettingsDialog.test.tsx.snap | 18 +++++++++--------- .../src/utils/terminalNotifications.test.ts | 8 +++++--- .../cli/src/utils/terminalNotifications.ts | 13 +++---------- schemas/settings.schema.json | 4 ++-- 17 files changed, 36 insertions(+), 41 deletions(-) diff --git a/docs/cli/notifications.md b/docs/cli/notifications.md index 8cff6c54f3..abe6743c56 100644 --- a/docs/cli/notifications.md +++ b/docs/cli/notifications.md @@ -15,14 +15,14 @@ CLI works in the background. ## Requirements -Currently, system notifications are only supported on macOS. - ### Terminal support The CLI uses the OSC 9 terminal escape sequence to trigger system notifications. -This is supported by several modern terminal emulators. If your terminal does -not support OSC 9 notifications, Gemini CLI falls back to a system alert sound -to get your attention. +This is supported by several modern terminal emulators including iTerm2, +WezTerm, Ghostty, and Kitty. If your terminal does not support OSC 9 +notifications, Gemini CLI falls back to a terminal bell (BEL) to get your +attention. Most terminals respond to BEL with a taskbar flash or system alert +sound. ## Enable notifications diff --git a/docs/cli/settings.md b/docs/cli/settings.md index 2792606959..5f432b8c8d 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -29,7 +29,7 @@ they appear in the UI. | Vim Mode | `general.vimMode` | Enable Vim keybindings | `false` | | Default Approval Mode | `general.defaultApprovalMode` | The default approval mode for tool execution. 'default' prompts for approval, 'auto_edit' auto-approves edit tools, and 'plan' is read-only mode. YOLO mode (auto-approve all actions) can only be enabled via command line (--yolo or --approval-mode=yolo). | `"default"` | | 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` | +| Enable Notifications | `general.enableNotifications` | Enable run-event notifications for action-required prompts and session completion. | `false` | | Plan Directory | `general.plan.directory` | The directory where planning artifacts are stored. If not specified, defaults to the system temporary directory. A custom directory requires a policy to allow write access in Plan Mode. | `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` | | Retry Fetch Errors | `general.retryFetchErrors` | Retry on "exception TypeError: fetch failed sending request" errors. | `true` | diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index ef325681ce..ec8f74de95 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -133,7 +133,7 @@ their corresponding top-level category object in your `settings.json` file. - **`general.enableNotifications`** (boolean): - **Description:** Enable run-event notifications for action-required prompts - and session completion. Currently macOS only. + and session completion. - **Default:** `false` - **`general.checkpointing.enabled`** (boolean): diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index aba97ca179..aec521317c 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -261,7 +261,7 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: false, description: - 'Enable run-event notifications for action-required prompts and session completion. Currently macOS only.', + 'Enable run-event notifications for action-required prompts and session completion.', showInDialog: true, }, checkpointing: { diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg index fc567671b8..655e9bce71 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Initial-Rendering-should-render-settings-list-with-visual-indicators.snap.svg @@ -71,7 +71,7 @@ false - Enable run-event notifications for action-required prompts and session completion. … + Enable run-event notifications for action-required prompts and session completion. diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg index a01eae091d..54b716a36b 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-accessibility-settings-enabled-correctly.snap.svg @@ -71,7 +71,7 @@ false - Enable run-event notifications for action-required prompts and session completion. … + Enable run-event notifications for action-required prompts and session completion. diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg index d777591e70..78dd34369d 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-all-boolean-settings-disabled-correctly.snap.svg @@ -71,7 +71,7 @@ false - Enable run-event notifications for action-required prompts and session completion. … + Enable run-event notifications for action-required prompts and session completion. diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg index fc567671b8..655e9bce71 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-default-state-correctly.snap.svg @@ -71,7 +71,7 @@ false - Enable run-event notifications for action-required prompts and session completion. … + Enable run-event notifications for action-required prompts and session completion. diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg index fc567671b8..655e9bce71 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-file-filtering-settings-configured-correctly.snap.svg @@ -71,7 +71,7 @@ false - Enable run-event notifications for action-required prompts and session completion. … + Enable run-event notifications for action-required prompts and session completion. diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg index 3d11268eff..3d1e8b7dc9 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-focused-on-scope-selector-correctly.snap.svg @@ -60,7 +60,7 @@ false - Enable run-event notifications for action-required prompts and session completion. … + Enable run-event notifications for action-required prompts and session completion. diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg index 0f619971c1..3868b38e23 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-mixed-boolean-and-number-settings-correctly.snap.svg @@ -71,7 +71,7 @@ false - Enable run-event notifications for action-required prompts and session completion. … + Enable run-event notifications for action-required prompts and session completion. diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg index fc567671b8..655e9bce71 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-tools-and-security-settings-correctly.snap.svg @@ -71,7 +71,7 @@ false - Enable run-event notifications for action-required prompts and session completion. … + Enable run-event notifications for action-required prompts and session completion. diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg index 3a7a0580ff..196b1e5ed1 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog-SettingsDialog-Snapshot-Tests-should-render-various-boolean-settings-enabled-correctly.snap.svg @@ -71,7 +71,7 @@ false - Enable run-event notifications for action-required prompts and session completion. … + Enable run-event notifications for action-required prompts and session completion. diff --git a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap index 19158681b2..a3d3581677 100644 --- a/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/SettingsDialog.test.tsx.snap @@ -20,7 +20,7 @@ exports[`SettingsDialog > Initial Rendering > should render settings list with v │ Enable automatic updates. │ │ │ │ Enable Notifications false │ -│ Enable run-event notifications for action-required prompts and session completion. … │ +│ Enable run-event notifications for action-required prompts and session completion. │ │ │ │ Plan Directory undefined │ │ The directory where planning artifacts are stored. If not specified, defaults t… │ @@ -66,7 +66,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'accessibility settings │ Enable automatic updates. │ │ │ │ Enable Notifications false │ -│ Enable run-event notifications for action-required prompts and session completion. … │ +│ Enable run-event notifications for action-required prompts and session completion. │ │ │ │ Plan Directory undefined │ │ The directory where planning artifacts are stored. If not specified, defaults t… │ @@ -112,7 +112,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'all boolean settings d │ Enable automatic updates. │ │ │ │ Enable Notifications false │ -│ Enable run-event notifications for action-required prompts and session completion. … │ +│ Enable run-event notifications for action-required prompts and session completion. │ │ │ │ Plan Directory undefined │ │ The directory where planning artifacts are stored. If not specified, defaults t… │ @@ -158,7 +158,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'default state' correct │ Enable automatic updates. │ │ │ │ Enable Notifications false │ -│ Enable run-event notifications for action-required prompts and session completion. … │ +│ Enable run-event notifications for action-required prompts and session completion. │ │ │ │ Plan Directory undefined │ │ The directory where planning artifacts are stored. If not specified, defaults t… │ @@ -204,7 +204,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'file filtering setting │ Enable automatic updates. │ │ │ │ Enable Notifications false │ -│ Enable run-event notifications for action-required prompts and session completion. … │ +│ Enable run-event notifications for action-required prompts and session completion. │ │ │ │ Plan Directory undefined │ │ The directory where planning artifacts are stored. If not specified, defaults t… │ @@ -250,7 +250,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'focused on scope selec │ Enable automatic updates. │ │ │ │ Enable Notifications false │ -│ Enable run-event notifications for action-required prompts and session completion. … │ +│ Enable run-event notifications for action-required prompts and session completion. │ │ │ │ Plan Directory undefined │ │ The directory where planning artifacts are stored. If not specified, defaults t… │ @@ -296,7 +296,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'mixed boolean and numb │ Enable automatic updates. │ │ │ │ Enable Notifications false │ -│ Enable run-event notifications for action-required prompts and session completion. … │ +│ Enable run-event notifications for action-required prompts and session completion. │ │ │ │ Plan Directory undefined │ │ The directory where planning artifacts are stored. If not specified, defaults t… │ @@ -342,7 +342,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'tools and security set │ Enable automatic updates. │ │ │ │ Enable Notifications false │ -│ Enable run-event notifications for action-required prompts and session completion. … │ +│ Enable run-event notifications for action-required prompts and session completion. │ │ │ │ Plan Directory undefined │ │ The directory where planning artifacts are stored. If not specified, defaults t… │ @@ -388,7 +388,7 @@ exports[`SettingsDialog > Snapshot Tests > should render 'various boolean settin │ Enable automatic updates. │ │ │ │ Enable Notifications false │ -│ Enable run-event notifications for action-required prompts and session completion. … │ +│ Enable run-event notifications for action-required prompts and session completion. │ │ │ │ Plan Directory undefined │ │ The directory where planning artifacts are stored. If not specified, defaults t… │ diff --git a/packages/cli/src/utils/terminalNotifications.test.ts b/packages/cli/src/utils/terminalNotifications.test.ts index 7efa1c4f34..f05e650325 100644 --- a/packages/cli/src/utils/terminalNotifications.test.ts +++ b/packages/cli/src/utils/terminalNotifications.test.ts @@ -43,7 +43,7 @@ describe('terminal notifications', () => { }); }); - it('returns false without writing on non-macOS platforms', async () => { + it('emits notification on non-macOS platforms', async () => { Object.defineProperty(process, 'platform', { value: 'linux', configurable: true, @@ -54,8 +54,8 @@ describe('terminal notifications', () => { body: 'b', }); - expect(shown).toBe(false); - expect(writeToStdout).not.toHaveBeenCalled(); + expect(shown).toBe(true); + expect(writeToStdout).toHaveBeenCalled(); }); it('returns false without writing when disabled', async () => { @@ -69,6 +69,7 @@ describe('terminal notifications', () => { }); it('emits OSC 9 notification when supported terminal is detected', async () => { + vi.stubEnv('WT_SESSION', ''); vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); const shown = await notifyViaTerminal(true, { @@ -126,6 +127,7 @@ describe('terminal notifications', () => { }); it('strips terminal control sequences and newlines from payload text', async () => { + vi.stubEnv('WT_SESSION', ''); vi.stubEnv('TERM_PROGRAM', 'iTerm.app'); const shown = await notifyViaTerminal(true, { diff --git a/packages/cli/src/utils/terminalNotifications.ts b/packages/cli/src/utils/terminalNotifications.ts index d774e852d3..c0ad259a4b 100644 --- a/packages/cli/src/utils/terminalNotifications.ts +++ b/packages/cli/src/utils/terminalNotifications.ts @@ -75,17 +75,10 @@ export function buildRunEventNotificationContent( export function isNotificationsEnabled(settings: LoadedSettings): boolean { const general = settings.merged.general as - | { - enableNotifications?: boolean; - enableMacOsNotifications?: boolean; - } + | { enableNotifications?: boolean } | undefined; - return ( - process.platform === 'darwin' && - (general?.enableNotifications === true || - general?.enableMacOsNotifications === true) - ); + return general?.enableNotifications === true; } function buildTerminalNotificationMessage( @@ -112,7 +105,7 @@ export async function notifyViaTerminal( notificationsEnabled: boolean, content: RunEventNotificationContent, ): Promise { - if (!notificationsEnabled || process.platform !== 'darwin') { + if (!notificationsEnabled) { return false; } diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 28194b587c..74988cb240 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -93,8 +93,8 @@ }, "enableNotifications": { "title": "Enable Notifications", - "description": "Enable run-event notifications for action-required prompts and session completion. Currently macOS only.", - "markdownDescription": "Enable run-event notifications for action-required prompts and session completion. Currently macOS only.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", + "description": "Enable run-event notifications for action-required prompts and session completion.", + "markdownDescription": "Enable run-event notifications for action-required prompts and session completion.\n\n- Category: `General`\n- Requires restart: `no`\n- Default: `false`", "default": false, "type": "boolean" }, From 30397816da37a7b766c2991fa7036b06dbd2f271 Mon Sep 17 00:00:00 2001 From: David Pierce Date: Thu, 26 Mar 2026 20:35:21 +0000 Subject: [PATCH 45/49] feat(sandbox): implement secret visibility lockdown for env files (#23712) Co-authored-by: Tommaso Sciortino --- .../sandbox/linux/LinuxSandboxManager.test.ts | 64 +++ .../src/sandbox/linux/LinuxSandboxManager.ts | 122 ++++- .../src/sandbox/macos/seatbeltArgsBuilder.ts | 49 ++ .../core/src/sandbox/windows/GeminiSandbox.cs | 487 +++++++++--------- .../windows/WindowsSandboxManager.test.ts | 9 +- .../sandbox/windows/WindowsSandboxManager.ts | 126 +++-- .../core/src/services/sandboxManager.test.ts | 160 +++++- packages/core/src/services/sandboxManager.ts | 82 +++ 8 files changed, 800 insertions(+), 299 deletions(-) diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts index b58fe271f6..f88e9e76e2 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.test.ts @@ -8,6 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { LinuxSandboxManager } from './LinuxSandboxManager.js'; import type { SandboxRequest } from '../../services/sandboxManager.js'; import fs from 'node:fs'; +import * as shellUtils from '../../utils/shell-utils.js'; vi.mock('node:fs', async () => { const actual = await vi.importActual('node:fs'); @@ -20,17 +21,40 @@ vi.mock('node:fs', async () => { realpathSync: vi.fn((p) => p.toString()), statSync: vi.fn(() => ({ isDirectory: () => true }) as fs.Stats), mkdirSync: vi.fn(), + mkdtempSync: vi.fn((prefix: string) => prefix + 'mocked'), openSync: vi.fn(), closeSync: vi.fn(), writeFileSync: vi.fn(), + readdirSync: vi.fn(() => []), + chmodSync: vi.fn(), + unlinkSync: vi.fn(), + rmSync: vi.fn(), }, existsSync: vi.fn(() => true), realpathSync: vi.fn((p) => p.toString()), statSync: vi.fn(() => ({ isDirectory: () => true }) as fs.Stats), mkdirSync: vi.fn(), + mkdtempSync: vi.fn((prefix: string) => prefix + 'mocked'), openSync: vi.fn(), closeSync: vi.fn(), writeFileSync: vi.fn(), + readdirSync: vi.fn(() => []), + chmodSync: vi.fn(), + unlinkSync: vi.fn(), + rmSync: vi.fn(), + }; +}); + +vi.mock('../../utils/shell-utils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + spawnAsync: vi.fn(() => + Promise.resolve({ status: 0, stdout: Buffer.from('') }), + ), + initializeShellParsers: vi.fn(), + isStrictlyApproved: vi.fn().mockResolvedValue(true), }; }); @@ -452,4 +476,44 @@ describe('LinuxSandboxManager', () => { }); }); }); + + it('blocks .env and .env.* files in the workspace root', async () => { + vi.mocked(shellUtils.spawnAsync).mockImplementation((cmd, args) => { + if (cmd === 'find' && args?.[0] === workspace) { + // Assert that find is NOT excluding dotfiles + expect(args).not.toContain('-not'); + expect(args).toContain('-prune'); + + return Promise.resolve({ + status: 0, + stdout: Buffer.from( + `${workspace}/.env\0${workspace}/.env.local\0${workspace}/.env.test\0`, + ), + } as unknown as ReturnType); + } + return Promise.resolve({ + status: 0, + stdout: Buffer.from(''), + } as unknown as ReturnType); + }); + + const bwrapArgs = await getBwrapArgs({ + command: 'ls', + args: [], + cwd: workspace, + env: {}, + }); + + const bindsIndex = bwrapArgs.indexOf('--seccomp'); + const binds = bwrapArgs.slice(0, bindsIndex); + + expect(binds).toContain(`${workspace}/.env`); + expect(binds).toContain(`${workspace}/.env.local`); + expect(binds).toContain(`${workspace}/.env.test`); + + // Verify they are bound to a mask file + const envIndex = binds.indexOf(`${workspace}/.env`); + expect(binds[envIndex - 2]).toBe('--bind'); + expect(binds[envIndex - 1]).toMatch(/gemini-cli-mask-file-.*mocked\/mask/); + }); }); diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts index 33f12beafa..28be7ad281 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts @@ -5,7 +5,6 @@ */ import fs from 'node:fs'; -import { debugLogger } from '../../utils/debugLogger.js'; import { join, dirname, normalize } from 'node:path'; import os from 'node:os'; import { @@ -15,12 +14,15 @@ import { type SandboxedCommand, type SandboxPermissions, GOVERNANCE_FILES, + getSecretFileFindArgs, sanitizePaths, } from '../../services/sandboxManager.js'; import { sanitizeEnvironment, getSecureSanitizationConfig, } from '../../services/environmentSanitization.js'; +import { debugLogger } from '../../utils/debugLogger.js'; +import { spawnAsync } from '../../utils/shell-utils.js'; import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js'; import { isStrictlyApproved, @@ -32,6 +34,10 @@ import { resolveGitWorktreePaths, isErrnoException, } from '../utils/fsUtils.js'; +import { + isKnownSafeCommand, + isDangerousCommand, +} from '../utils/commandSafety.js'; let cachedBpfPath: string | undefined; @@ -85,9 +91,20 @@ function getSeccompBpfPath(): string { buf.writeUInt32LE(inst.k, offset + 4); } - const bpfPath = join(os.tmpdir(), `gemini-cli-seccomp-${process.pid}.bpf`); + const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'gemini-cli-seccomp-')); + const bpfPath = join(tempDir, 'seccomp.bpf'); fs.writeFileSync(bpfPath, buf); cachedBpfPath = bpfPath; + + // Cleanup on exit + process.on('exit', () => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore errors + } + }); + return bpfPath; } @@ -110,11 +127,6 @@ function touch(filePath: string, isDirectory: boolean) { } } -import { - isKnownSafeCommand, - isDangerousCommand, -} from '../utils/commandSafety.js'; - /** * A SandboxManager implementation for Linux that uses Bubblewrap (bwrap). */ @@ -130,6 +142,8 @@ export interface LinuxSandboxOptions extends GlobalSandboxOptions { } export class LinuxSandboxManager implements SandboxManager { + private static maskFilePath: string | undefined; + constructor(private readonly options: LinuxSandboxOptions) {} isKnownSafeCommand(args: string[]): boolean { @@ -140,6 +154,31 @@ export class LinuxSandboxManager implements SandboxManager { return isDangerousCommand(args); } + private getMaskFilePath(): string { + if ( + LinuxSandboxManager.maskFilePath && + fs.existsSync(LinuxSandboxManager.maskFilePath) + ) { + return LinuxSandboxManager.maskFilePath; + } + const tempDir = fs.mkdtempSync(join(os.tmpdir(), 'gemini-cli-mask-file-')); + const maskPath = join(tempDir, 'mask'); + fs.writeFileSync(maskPath, ''); + fs.chmodSync(maskPath, 0); + LinuxSandboxManager.maskFilePath = maskPath; + + // Cleanup on exit + process.on('exit', () => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore errors + } + }); + + return maskPath; + } + async prepareCommand(req: SandboxRequest): Promise { const isReadonlyMode = this.options.modeConfig?.readonly ?? true; const allowOverrides = this.options.modeConfig?.allowOverrides ?? true; @@ -319,6 +358,11 @@ export class LinuxSandboxManager implements SandboxManager { } } + // Mask secret files (.env, .env.*) + bwrapArgs.push( + ...(await this.getSecretFilesArgs(req.policy?.allowedPaths)), + ); + const bpfPath = getSeccompBpfPath(); bwrapArgs.push('--seccomp', '9'); @@ -339,4 +383,68 @@ export class LinuxSandboxManager implements SandboxManager { cwd: req.cwd, }; } + + /** + * Generates bubblewrap arguments to mask secret files. + */ + private async getSecretFilesArgs(allowedPaths?: string[]): Promise { + const args: string[] = []; + const maskPath = this.getMaskFilePath(); + const paths = sanitizePaths(allowedPaths) || []; + const searchDirs = new Set([this.options.workspace, ...paths]); + const findPatterns = getSecretFileFindArgs(); + + for (const dir of searchDirs) { + try { + // Use the native 'find' command for performance and to catch nested secrets. + // We limit depth to 3 to keep it fast while covering common nested structures. + // We use -prune to skip heavy directories efficiently while matching dotfiles. + const findResult = await spawnAsync('find', [ + dir, + '-maxdepth', + '3', + '-type', + 'd', + '(', + '-name', + '.git', + '-o', + '-name', + 'node_modules', + '-o', + '-name', + '.venv', + '-o', + '-name', + '__pycache__', + '-o', + '-name', + 'dist', + '-o', + '-name', + 'build', + ')', + '-prune', + '-o', + '-type', + 'f', + ...findPatterns, + '-print0', + ]); + + const files = findResult.stdout.toString().split('\0'); + for (const file of files) { + if (file.trim()) { + args.push('--bind', maskPath, file.trim()); + } + } + } catch (e) { + debugLogger.log( + `LinuxSandboxManager: Failed to find or mask secret files in ${dir}`, + e, + ); + } + } + return args; + } } diff --git a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts index cfdcee1687..a610331d88 100644 --- a/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts +++ b/packages/core/src/sandbox/macos/seatbeltArgsBuilder.ts @@ -15,6 +15,7 @@ import { type SandboxPermissions, sanitizePaths, GOVERNANCE_FILES, + SECRET_FILES, } from '../../services/sandboxManager.js'; import { tryRealpath, resolveGitWorktreePaths } from '../utils/fsUtils.js'; @@ -89,6 +90,34 @@ export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] { } } + // Add explicit deny rules for secret files (.env, .env.*) in the workspace and allowed paths. + // We use regex rules to avoid expensive file discovery scans. + // Anchoring to workspace/allowed paths to avoid over-blocking. + const searchPaths = sanitizePaths([ + options.workspace, + ...(options.allowedPaths || []), + ]) || [options.workspace]; + + for (const basePath of searchPaths) { + const resolvedBase = tryRealpath(basePath); + for (const secret of SECRET_FILES) { + // Map pattern to Seatbelt regex + let regexPattern: string; + const escapedBase = escapeRegex(resolvedBase); + if (secret.pattern.endsWith('*')) { + // .env.* -> .env\..+ (match .env followed by dot and something) + // We anchor the secret file name to either a directory separator or the start of the relative path. + const basePattern = secret.pattern.slice(0, -1).replace(/\./g, '\\\\.'); + regexPattern = `^${escapedBase}/(.*/)?${basePattern}[^/]+$`; + } else { + // .env -> \.env$ + const basePattern = secret.pattern.replace(/\./g, '\\\\.'); + regexPattern = `^${escapedBase}/(.*/)?${basePattern}$`; + } + profile += `(deny file-read* file-write* (regex #"${regexPattern}"))\n`; + } + } + // Auto-detect and support git worktrees by granting read and write access to the underlying git directory const { worktreeGitDir, mainGitDir } = resolveGitWorktreePaths(workspacePath); if (worktreeGitDir) { @@ -206,3 +235,23 @@ export function buildSeatbeltArgs(options: SeatbeltArgsOptions): string[] { return args; } + +/** + * Escapes a string for use within a Seatbelt regex literal #"..." + */ +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\"]/g, (c) => { + if (c === '"') { + // Escape double quotes for the Scheme string literal + return '\\"'; + } + if (c === '\\') { + // A literal backslash needs to be \\ in the regex. + // To get \\ in the regex engine, we need \\\\ in the Scheme string literal. + return '\\\\\\\\'; + } + // For other regex special characters (like .), we need \c in the regex. + // To get \c in the regex engine, we need \\c in the Scheme string literal. + return '\\\\' + c; + }); +} diff --git a/packages/core/src/sandbox/windows/GeminiSandbox.cs b/packages/core/src/sandbox/windows/GeminiSandbox.cs index 8c3fc9de06..eff5ec703a 100644 --- a/packages/core/src/sandbox/windows/GeminiSandbox.cs +++ b/packages/core/src/sandbox/windows/GeminiSandbox.cs @@ -5,45 +5,28 @@ */ using System; -using System.Runtime.InteropServices; using System.Collections.Generic; using System.Diagnostics; -using System.Security.Principal; using System.IO; +using System.Runtime.InteropServices; +using System.Security; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Text; +/** + * A native C# helper for the Gemini CLI sandbox on Windows. + * This helper uses Restricted Tokens and Job Objects to isolate processes. + * It also supports internal commands for safe file I/O within the sandbox. + */ public class GeminiSandbox { - [StructLayout(LayoutKind.Sequential)] - public struct STARTUPINFO { - public uint cb; - public string lpReserved; - public string lpDesktop; - public string lpTitle; - public uint dwX; - public uint dwY; - public uint dwXSize; - public uint dwYSize; - public uint dwXCountChars; - public uint dwYCountChars; - public uint dwFillAttribute; - public uint dwFlags; - public ushort wShowWindow; - public ushort cbReserved2; - public IntPtr lpReserved2; - public IntPtr hStdInput; - public IntPtr hStdOutput; - public IntPtr hStdError; - } + // P/Invoke constants and structures + private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; + private const uint JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION = 0x00000400; + private const uint JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 0x00000008; [StructLayout(LayoutKind.Sequential)] - public struct PROCESS_INFORMATION { - public IntPtr hProcess; - public IntPtr hThread; - public uint dwProcessId; - public uint dwThreadId; - } - - [StructLayout(LayoutKind.Sequential)] - public struct JOBOBJECT_BASIC_LIMIT_INFORMATION { + struct JOBOBJECT_BASIC_LIMIT_INFORMATION { public Int64 PerProcessUserTimeLimit; public Int64 PerJobUserTimeLimit; public uint LimitFlags; @@ -56,17 +39,7 @@ public class GeminiSandbox { } [StructLayout(LayoutKind.Sequential)] - public struct IO_COUNTERS { - public ulong ReadOperationCount; - public ulong WriteOperationCount; - public ulong OtherOperationCount; - public ulong ReadTransferCount; - public ulong WriteTransferCount; - public ulong OtherTransferCount; - } - - [StructLayout(LayoutKind.Sequential)] - public struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION { + struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION { public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation; public IO_COUNTERS IoInfo; public UIntPtr ProcessMemoryLimit; @@ -76,139 +49,153 @@ public class GeminiSandbox { } [StructLayout(LayoutKind.Sequential)] - public struct SID_AND_ATTRIBUTES { + struct IO_COUNTERS { + public ulong ReadOperationCount; + public ulong WriteOperationCount; + public ulong OtherOperationCount; + public ulong ReadTransferCount; + public ulong WriteTransferCount; + public ulong OtherTransferCount; + } + + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetInformationJobObject(IntPtr hJob, int JobObjectInfoClass, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess); + + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle); + + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool CreateRestrictedToken(IntPtr ExistingTokenHandle, uint Flags, uint DisableSidCount, IntPtr SidsToDisable, uint DeletePrivilegeCount, IntPtr PrivilegesToDelete, uint RestrictedSidCount, IntPtr SidsToRestrict, out IntPtr NewTokenHandle); + + [DllImport("advapi32.dll", CharSet = CharSet.Auto, SetLastError = true)] + static extern bool CreateProcessAsUser(IntPtr hToken, string lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr GetCurrentProcess(); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool CloseHandle(IntPtr hObject); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr GetStdHandle(int nStdHandle); + + [StructLayout(LayoutKind.Sequential)] + struct STARTUPINFO { + public uint cb; + public string lpReserved; + public string lpDesktop; + public string lpTitle; + public uint dwX; + public uint dwY; + public uint dwXSize; + public uint dwYSize; + public uint dwXCountChars; + public uint dwYCountChars; + public uint dwFillAttribute; + public uint dwFlags; + public short wShowWindow; + public short cbReserved2; + public IntPtr lpReserved2; + public IntPtr hStdInput; + public IntPtr hStdOutput; + public IntPtr hStdError; + } + + [StructLayout(LayoutKind.Sequential)] + struct PROCESS_INFORMATION { + public IntPtr hProcess; + public IntPtr hThread; + public uint dwProcessId; + public uint dwThreadId; + } + + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool ImpersonateLoggedOnUser(IntPtr hToken); + + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool RevertToSelf(); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + static extern uint GetLongPathName(string lpszShortPath, [Out] StringBuilder lpszLongPath, uint cchBuffer); + + [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Auto)] + static extern bool ConvertStringSidToSid(string StringSid, out IntPtr ptrSid); + + [DllImport("advapi32.dll", SetLastError = true)] + static extern bool SetTokenInformation(IntPtr TokenHandle, int TokenInformationClass, IntPtr TokenInformation, uint TokenInformationLength); + + [StructLayout(LayoutKind.Sequential)] + struct SID_AND_ATTRIBUTES { public IntPtr Sid; public uint Attributes; } [StructLayout(LayoutKind.Sequential)] - public struct TOKEN_MANDATORY_LABEL { + struct TOKEN_MANDATORY_LABEL { public SID_AND_ATTRIBUTES Label; } - public enum JobObjectInfoClass { - ExtendedLimitInformation = 9 - } - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern IntPtr GetCurrentProcess(); - - [DllImport("advapi32.dll", SetLastError = true)] - public static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle); - - [DllImport("advapi32.dll", SetLastError = true)] - public static extern bool CreateRestrictedToken(IntPtr ExistingTokenHandle, uint Flags, uint DisableSidCount, IntPtr SidsToDisable, uint DeletePrivilegeCount, IntPtr PrivilegesToDelete, uint RestrictedSidCount, IntPtr SidsToRestrict, out IntPtr NewTokenHandle); - - [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - public static extern bool CreateProcessAsUser(IntPtr hToken, string lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation); - - [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - public static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern bool SetInformationJobObject(IntPtr hJob, JobObjectInfoClass JobObjectInfoClass, IntPtr lpJobObjectInfo, uint cbJobObjectInfoLength); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern uint ResumeThread(IntPtr hThread); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern bool CloseHandle(IntPtr hObject); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern IntPtr GetStdHandle(int nStdHandle); - - [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)] - public static extern bool ConvertStringSidToSid(string StringSid, out IntPtr Sid); - - [DllImport("advapi32.dll", SetLastError = true)] - public static extern bool SetTokenInformation(IntPtr TokenHandle, int TokenInformationClass, IntPtr TokenInformation, uint TokenInformationLength); - - [DllImport("kernel32.dll", SetLastError = true)] - public static extern IntPtr LocalFree(IntPtr hMem); - - public const uint TOKEN_DUPLICATE = 0x0002; - public const uint TOKEN_QUERY = 0x0008; - public const uint TOKEN_ASSIGN_PRIMARY = 0x0001; - public const uint TOKEN_ADJUST_DEFAULT = 0x0080; - public const uint DISABLE_MAX_PRIVILEGE = 0x1; - public const uint CREATE_SUSPENDED = 0x00000004; - public const uint CREATE_UNICODE_ENVIRONMENT = 0x00000400; - public const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000; - public const uint STARTF_USESTDHANDLES = 0x00000100; - public const int TokenIntegrityLevel = 25; - public const uint SE_GROUP_INTEGRITY = 0x00000020; - public const uint INFINITE = 0xFFFFFFFF; + private const int TokenIntegrityLevel = 25; + private const uint SE_GROUP_INTEGRITY = 0x00000020; static int Main(string[] args) { if (args.Length < 3) { - Console.WriteLine("Usage: GeminiSandbox.exe [args...]"); + Console.WriteLine("Usage: GeminiSandbox.exe [--forbidden-manifest ] [args...]"); Console.WriteLine("Internal commands: __read , __write "); return 1; } bool networkAccess = args[0] == "1"; string cwd = args[1]; - string command = args[2]; + HashSet forbiddenPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + int argIndex = 2; + + if (argIndex < args.Length && args[argIndex] == "--forbidden-manifest") { + if (argIndex + 1 < args.Length) { + string manifestPath = args[argIndex + 1]; + if (File.Exists(manifestPath)) { + foreach (string line in File.ReadAllLines(manifestPath)) { + if (!string.IsNullOrWhiteSpace(line)) { + forbiddenPaths.Add(GetNormalizedPath(line.Trim())); + } + } + } + argIndex += 2; + } + } + + if (argIndex >= args.Length) { + Console.WriteLine("Error: Missing command"); + return 1; + } + + string command = args[argIndex]; IntPtr hToken = IntPtr.Zero; IntPtr hRestrictedToken = IntPtr.Zero; - IntPtr hJob = IntPtr.Zero; - IntPtr pSidsToDisable = IntPtr.Zero; - IntPtr pSidsToRestrict = IntPtr.Zero; - IntPtr networkSid = IntPtr.Zero; - IntPtr restrictedSid = IntPtr.Zero; IntPtr lowIntegritySid = IntPtr.Zero; try { - // 1. Setup Token - IntPtr hCurrentProcess = GetCurrentProcess(); - if (!OpenProcessToken(hCurrentProcess, TOKEN_DUPLICATE | TOKEN_QUERY | TOKEN_ASSIGN_PRIMARY | TOKEN_ADJUST_DEFAULT, out hToken)) { - Console.Error.WriteLine("Failed to open process token"); + // 1. Create Restricted Token + if (!OpenProcessToken(GetCurrentProcess(), 0x0002 /* TOKEN_DUPLICATE */ | 0x0008 /* TOKEN_QUERY */ | 0x0080 /* TOKEN_ADJUST_DEFAULT */, out hToken)) { + Console.WriteLine("Error: OpenProcessToken failed (" + Marshal.GetLastWin32Error() + ")"); return 1; } - uint sidCount = 0; - uint restrictCount = 0; - - // "networkAccess == false" implies Strict Sandbox Level 1. - if (!networkAccess) { - if (ConvertStringSidToSid("S-1-5-2", out networkSid)) { - sidCount = 1; - int saaSize = Marshal.SizeOf(typeof(SID_AND_ATTRIBUTES)); - pSidsToDisable = Marshal.AllocHGlobal(saaSize); - SID_AND_ATTRIBUTES saa = new SID_AND_ATTRIBUTES(); - saa.Sid = networkSid; - saa.Attributes = 0; - Marshal.StructureToPtr(saa, pSidsToDisable, false); - } - - // S-1-5-12 is Restricted Code SID - if (ConvertStringSidToSid("S-1-5-12", out restrictedSid)) { - restrictCount = 1; - int saaSize = Marshal.SizeOf(typeof(SID_AND_ATTRIBUTES)); - pSidsToRestrict = Marshal.AllocHGlobal(saaSize); - SID_AND_ATTRIBUTES saa = new SID_AND_ATTRIBUTES(); - saa.Sid = restrictedSid; - saa.Attributes = 0; - Marshal.StructureToPtr(saa, pSidsToRestrict, false); - } - } - - if (!CreateRestrictedToken(hToken, DISABLE_MAX_PRIVILEGE, sidCount, pSidsToDisable, 0, IntPtr.Zero, restrictCount, pSidsToRestrict, out hRestrictedToken)) { - Console.Error.WriteLine("Failed to create restricted token"); + // Flags: 0x1 (DISABLE_MAX_PRIVILEGE) + if (!CreateRestrictedToken(hToken, 1, 0, IntPtr.Zero, 0, IntPtr.Zero, 0, IntPtr.Zero, out hRestrictedToken)) { + Console.WriteLine("Error: CreateRestrictedToken failed (" + Marshal.GetLastWin32Error() + ")"); return 1; } - // 2. Set Integrity Level to Low + // 2. Lower Integrity Level to Low + // S-1-16-4096 is the SID for "Low Mandatory Level" if (ConvertStringSidToSid("S-1-16-4096", out lowIntegritySid)) { TOKEN_MANDATORY_LABEL tml = new TOKEN_MANDATORY_LABEL(); tml.Label.Sid = lowIntegritySid; @@ -217,154 +204,184 @@ public class GeminiSandbox { IntPtr pTml = Marshal.AllocHGlobal(tmlSize); try { Marshal.StructureToPtr(tml, pTml, false); - SetTokenInformation(hRestrictedToken, TokenIntegrityLevel, pTml, (uint)tmlSize); + if (!SetTokenInformation(hRestrictedToken, TokenIntegrityLevel, pTml, (uint)tmlSize)) { + Console.WriteLine("Error: SetTokenInformation failed (" + Marshal.GetLastWin32Error() + ")"); + return 1; + } } finally { Marshal.FreeHGlobal(pTml); } } - // 3. Handle Internal Commands or External Process + // 3. Setup Job Object for cleanup + IntPtr hJob = CreateJobObject(IntPtr.Zero, null); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION jobLimits = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); + jobLimits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION; + + IntPtr lpJobLimits = Marshal.AllocHGlobal(Marshal.SizeOf(jobLimits)); + Marshal.StructureToPtr(jobLimits, lpJobLimits, false); + SetInformationJobObject(hJob, 9 /* JobObjectExtendedLimitInformation */, lpJobLimits, (uint)Marshal.SizeOf(jobLimits)); + Marshal.FreeHGlobal(lpJobLimits); + + // 4. Handle Internal Commands or External Process if (command == "__read") { - string path = args[3]; + if (argIndex + 1 >= args.Length) { + Console.WriteLine("Error: Missing path for __read"); + return 1; + } + string path = args[argIndex + 1]; + CheckForbidden(path, forbiddenPaths); return RunInImpersonation(hRestrictedToken, () => { try { using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) - using (StreamReader sr = new StreamReader(fs, System.Text.Encoding.UTF8)) { - char[] buffer = new char[4096]; - int bytesRead; - while ((bytesRead = sr.Read(buffer, 0, buffer.Length)) > 0) { - Console.Write(buffer, 0, bytesRead); - } + using (Stream stdout = Console.OpenStandardOutput()) { + fs.CopyTo(stdout); } return 0; } catch (Exception e) { - Console.Error.WriteLine(e.Message); + Console.Error.WriteLine("Error reading file: " + e.Message); return 1; } }); } else if (command == "__write") { - string path = args[3]; + if (argIndex + 1 >= args.Length) { + Console.WriteLine("Error: Missing path for __write"); + return 1; + } + string path = args[argIndex + 1]; + CheckForbidden(path, forbiddenPaths); return RunInImpersonation(hRestrictedToken, () => { try { using (StreamReader reader = new StreamReader(Console.OpenStandardInput(), System.Text.Encoding.UTF8)) using (FileStream fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None)) using (StreamWriter writer = new StreamWriter(fs, System.Text.Encoding.UTF8)) { - char[] buffer = new char[4096]; - int bytesRead; - while ((bytesRead = reader.Read(buffer, 0, buffer.Length)) > 0) { - writer.Write(buffer, 0, bytesRead); - } + writer.Write(reader.ReadToEnd()); } return 0; } catch (Exception e) { - Console.Error.WriteLine(e.Message); + Console.Error.WriteLine("Error writing file: " + e.Message); return 1; } }); } - // 4. Setup Job Object for external process - hJob = CreateJobObject(IntPtr.Zero, null); - if (hJob != IntPtr.Zero) { - JOBOBJECT_EXTENDED_LIMIT_INFORMATION limitInfo = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION(); - limitInfo.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; - int limitSize = Marshal.SizeOf(limitInfo); - IntPtr pLimit = Marshal.AllocHGlobal(limitSize); - try { - Marshal.StructureToPtr(limitInfo, pLimit, false); - SetInformationJobObject(hJob, JobObjectInfoClass.ExtendedLimitInformation, pLimit, (uint)limitSize); - } finally { - Marshal.FreeHGlobal(pLimit); - } - } - - // 5. Launch Process + // External Process STARTUPINFO si = new STARTUPINFO(); si.cb = (uint)Marshal.SizeOf(si); - si.dwFlags = STARTF_USESTDHANDLES; + si.dwFlags = 0x00000100; // STARTF_USESTDHANDLES si.hStdInput = GetStdHandle(-10); si.hStdOutput = GetStdHandle(-11); si.hStdError = GetStdHandle(-12); string commandLine = ""; - for (int i = 2; i < args.Length; i++) { - if (i > 2) commandLine += " "; + for (int i = argIndex; i < args.Length; i++) { + if (i > argIndex) commandLine += " "; commandLine += QuoteArgument(args[i]); } - PROCESS_INFORMATION pi; - if (!CreateProcessAsUser(hRestrictedToken, null, commandLine, IntPtr.Zero, IntPtr.Zero, true, CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT, IntPtr.Zero, cwd, ref si, out pi)) { - Console.Error.WriteLine("Failed to create process. Error: " + Marshal.GetLastWin32Error()); + PROCESS_INFORMATION pi = new PROCESS_INFORMATION(); + // Creation Flags: 0x04000000 (CREATE_BREAKAWAY_FROM_JOB) to allow job assignment if parent is in job + uint creationFlags = 0; + if (!CreateProcessAsUser(hRestrictedToken, null, commandLine, IntPtr.Zero, IntPtr.Zero, true, creationFlags, IntPtr.Zero, cwd, ref si, out pi)) { + Console.WriteLine("Error: CreateProcessAsUser failed (" + Marshal.GetLastWin32Error() + ") Command: " + commandLine); return 1; } - try { - if (hJob != IntPtr.Zero) { - AssignProcessToJobObject(hJob, pi.hProcess); - } + AssignProcessToJobObject(hJob, pi.hProcess); + + // Wait for exit + uint waitResult = WaitForSingleObject(pi.hProcess, 0xFFFFFFFF); + uint exitCode = 0; + GetExitCodeProcess(pi.hProcess, out exitCode); - ResumeThread(pi.hThread); - WaitForSingleObject(pi.hProcess, INFINITE); + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + CloseHandle(hJob); - uint exitCode = 0; - GetExitCodeProcess(pi.hProcess, out exitCode); - return (int)exitCode; - } finally { - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); - } - } catch (Exception e) { - Console.Error.WriteLine("Unexpected error: " + e.Message); - return 1; + return (int)exitCode; } finally { - if (hRestrictedToken != IntPtr.Zero) CloseHandle(hRestrictedToken); if (hToken != IntPtr.Zero) CloseHandle(hToken); - if (hJob != IntPtr.Zero) CloseHandle(hJob); - if (pSidsToDisable != IntPtr.Zero) Marshal.FreeHGlobal(pSidsToDisable); - if (pSidsToRestrict != IntPtr.Zero) Marshal.FreeHGlobal(pSidsToRestrict); - if (networkSid != IntPtr.Zero) LocalFree(networkSid); - if (restrictedSid != IntPtr.Zero) LocalFree(restrictedSid); - if (lowIntegritySid != IntPtr.Zero) LocalFree(lowIntegritySid); + if (hRestrictedToken != IntPtr.Zero) CloseHandle(hRestrictedToken); + } + } + + [DllImport("kernel32.dll", SetLastError = true)] + static extern uint WaitForSingleObject(IntPtr hHandle, uint dwMilliseconds); + + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool GetExitCodeProcess(IntPtr hProcess, out uint lpExitCode); + + private static int RunInImpersonation(IntPtr hToken, Func action) { + if (!ImpersonateLoggedOnUser(hToken)) { + Console.WriteLine("Error: ImpersonateLoggedOnUser failed (" + Marshal.GetLastWin32Error() + ")"); + return 1; + } + try { + return action(); + } finally { + RevertToSelf(); + } + } + + private static string GetNormalizedPath(string path) { + string fullPath = Path.GetFullPath(path); + StringBuilder longPath = new StringBuilder(1024); + uint result = GetLongPathName(fullPath, longPath, (uint)longPath.Capacity); + if (result > 0 && result < longPath.Capacity) { + return longPath.ToString(); + } + return fullPath; + } + + private static void CheckForbidden(string path, HashSet forbiddenPaths) { + string fullPath = GetNormalizedPath(path); + foreach (string forbidden in forbiddenPaths) { + if (fullPath.Equals(forbidden, StringComparison.OrdinalIgnoreCase) || fullPath.StartsWith(forbidden + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) { + throw new UnauthorizedAccessException("Access to forbidden path is denied: " + path); + } } } private static string QuoteArgument(string arg) { if (string.IsNullOrEmpty(arg)) return "\"\""; - bool hasSpace = arg.IndexOfAny(new char[] { ' ', '\t' }) != -1; - if (!hasSpace && arg.IndexOf('\"') == -1) return arg; + bool needsQuotes = false; + foreach (char c in arg) { + if (char.IsWhiteSpace(c) || c == '\"') { + needsQuotes = true; + break; + } + } - // Windows command line escaping for arguments is complex. - // Rule: Backslashes only need escaping if they precede a double quote or the end of the string. - System.Text.StringBuilder sb = new System.Text.StringBuilder(); + if (!needsQuotes) return arg; + + StringBuilder sb = new StringBuilder(); sb.Append('\"'); for (int i = 0; i < arg.Length; i++) { - int backslashCount = 0; - while (i < arg.Length && arg[i] == '\\') { - backslashCount++; - i++; - } + char c = arg[i]; + if (c == '\"') { + sb.Append("\\\""); + } else if (c == '\\') { + int backslashCount = 0; + while (i < arg.Length && arg[i] == '\\') { + backslashCount++; + i++; + } - if (i == arg.Length) { - // Escape backslashes before the closing double quote - sb.Append('\\', backslashCount * 2); - } else if (arg[i] == '\"') { - // Escape backslashes before a literal double quote - sb.Append('\\', backslashCount * 2 + 1); - sb.Append('\"'); + if (i == arg.Length) { + sb.Append('\\', backslashCount * 2); + } else if (arg[i] == '\"') { + sb.Append('\\', backslashCount * 2 + 1); + sb.Append('\"'); + } else { + sb.Append('\\', backslashCount); + sb.Append(arg[i]); + } } else { - // Backslashes don't need escaping here - sb.Append('\\', backslashCount); - sb.Append(arg[i]); + sb.Append(c); } } sb.Append('\"'); return sb.ToString(); } - - private static int RunInImpersonation(IntPtr hToken, Func action) { - using (WindowsIdentity.Impersonate(hToken)) { - return action(); - } - } } diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts index 2c7e08a730..37b01be9bc 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.test.ts @@ -60,7 +60,14 @@ describe('WindowsSandboxManager', () => { const result = await manager.prepareCommand(req); expect(result.program).toContain('GeminiSandbox.exe'); - expect(result.args).toEqual(['0', testCwd, 'whoami', '/groups']); + expect(result.args).toEqual([ + '0', + testCwd, + '--forbidden-manifest', + expect.stringMatching(/manifest\.txt$/), + 'whoami', + '/groups', + ]); }); it('should handle networkAccess from config', async () => { diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts index a213d7b619..a07241366a 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts @@ -13,6 +13,7 @@ import { type SandboxRequest, type SandboxedCommand, GOVERNANCE_FILES, + findSecretFiles, type GlobalSandboxOptions, sanitizePaths, tryRealpath, @@ -269,43 +270,96 @@ export class WindowsSandboxManager implements SandboxManager { await this.grantLowIntegrityAccess(writePath); } - // Denies access to forbiddenPaths for Low Integrity processes. - const forbiddenPaths = sanitizePaths(req.policy?.forbiddenPaths) || []; - for (const forbiddenPath of forbiddenPaths) { - await this.denyLowIntegrityAccess(forbiddenPath); + // 2. Collect secret files and apply protective ACLs + // On Windows, we explicitly deny access to secret files for Low Integrity + // processes to ensure they cannot be read or written. + const secretsToBlock: string[] = []; + const searchDirs = new Set([this.options.workspace, ...allowedPaths]); + for (const dir of searchDirs) { + try { + // We use maxDepth 3 to catch common nested secrets while keeping performance high. + const secretFiles = await findSecretFiles(dir, 3); + for (const secretFile of secretFiles) { + try { + secretsToBlock.push(secretFile); + await this.denyLowIntegrityAccess(secretFile); + } catch (e) { + debugLogger.log( + `WindowsSandboxManager: Failed to secure secret file ${secretFile}`, + e, + ); + } + } + } catch (e) { + debugLogger.log( + `WindowsSandboxManager: Failed to find secret files in ${dir}`, + e, + ); + } } - // 2. Protected governance files + // Denies access to forbiddenPaths for Low Integrity processes. + // Note: Denying access to arbitrary paths (like system files) via icacls + // is restricted to avoid host corruption. External commands rely on + // Low Integrity read/write restrictions, while internal commands + // use the manifest for enforcement. + const forbiddenPaths = sanitizePaths(req.policy?.forbiddenPaths) || []; + for (const forbiddenPath of forbiddenPaths) { + try { + await this.denyLowIntegrityAccess(forbiddenPath); + } catch (e) { + debugLogger.log( + `WindowsSandboxManager: Failed to secure forbidden path ${forbiddenPath}`, + e, + ); + } + } + + // 3. Protected governance files // These must exist on the host before running the sandbox to prevent // the sandboxed process from creating them with Low integrity. // By being created as Medium integrity, they are write-protected from Low processes. for (const file of GOVERNANCE_FILES) { const filePath = path.join(this.options.workspace, file.path); this.touch(filePath, file.isDirectory); - - // We resolve real paths to ensure protection for both the symlink and its target. - try { - const realPath = fs.realpathSync(filePath); - if (realPath !== filePath) { - // If it's a symlink, the target is already implicitly protected - // if it's outside the Low integrity workspace (likely Medium). - // If it's inside, we ensure it's not accidentally Low. - } - } catch { - // Ignore realpath errors - } } - // 3. Construct the helper command - // GeminiSandbox.exe [args...] + // 4. Forbidden paths manifest + // We use a manifest file to avoid command-line length limits. + const allForbidden = Array.from( + new Set([...secretsToBlock, ...forbiddenPaths]), + ); + const tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-cli-forbidden-'), + ); + const manifestPath = path.join(tempDir, 'manifest.txt'); + fs.writeFileSync(manifestPath, allForbidden.join('\n')); + + // Cleanup on exit + process.on('exit', () => { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore errors + } + }); + + // 5. Construct the helper command + // GeminiSandbox.exe --forbidden-manifest [args...] const program = this.helperPath; const defaultNetwork = this.options.modeConfig?.network ?? req.policy?.networkAccess ?? false; const networkAccess = defaultNetwork || mergedAdditional.network; - // If the command starts with __, it's an internal command for the sandbox helper itself. - const args = [networkAccess ? '1' : '0', req.cwd, req.command, ...req.args]; + const args = [ + networkAccess ? '1' : '0', + req.cwd, + '--forbidden-manifest', + manifestPath, + req.command, + ...req.args, + ]; return { program, @@ -342,17 +396,7 @@ export class WindowsSandboxManager implements SandboxManager { return; } - // Never modify integrity levels for system directories - const systemRoot = process.env['SystemRoot'] || 'C:\\Windows'; - const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files'; - const programFilesX86 = - process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; - - if ( - resolvedPath.toLowerCase().startsWith(systemRoot.toLowerCase()) || - resolvedPath.toLowerCase().startsWith(programFiles.toLowerCase()) || - resolvedPath.toLowerCase().startsWith(programFilesX86.toLowerCase()) - ) { + if (this.isSystemDirectory(resolvedPath)) { return; } @@ -381,6 +425,11 @@ export class WindowsSandboxManager implements SandboxManager { return; } + // Never modify ACEs for system directories + if (this.isSystemDirectory(resolvedPath)) { + return; + } + // S-1-16-4096 is the SID for "Low Mandatory Level" (Low Integrity) const LOW_INTEGRITY_SID = '*S-1-16-4096'; @@ -417,4 +466,17 @@ export class WindowsSandboxManager implements SandboxManager { ); } } + + private isSystemDirectory(resolvedPath: string): boolean { + const systemRoot = process.env['SystemRoot'] || 'C:\\Windows'; + const programFiles = process.env['ProgramFiles'] || 'C:\\Program Files'; + const programFilesX86 = + process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; + + return ( + resolvedPath.toLowerCase().startsWith(systemRoot.toLowerCase()) || + resolvedPath.toLowerCase().startsWith(programFiles.toLowerCase()) || + resolvedPath.toLowerCase().startsWith(programFilesX86.toLowerCase()) + ); + } } diff --git a/packages/core/src/services/sandboxManager.test.ts b/packages/core/src/services/sandboxManager.test.ts index a677c790b1..a62a7e50cb 100644 --- a/packages/core/src/services/sandboxManager.test.ts +++ b/packages/core/src/services/sandboxManager.test.ts @@ -3,20 +3,120 @@ * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ + import os from 'node:os'; import path from 'node:path'; -import fs from 'node:fs/promises'; +import fsPromises from 'node:fs/promises'; import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest'; import { NoopSandboxManager, LocalSandboxManager, sanitizePaths, + findSecretFiles, + isSecretFile, tryRealpath, } from './sandboxManager.js'; import { createSandboxManager } from './sandboxManagerFactory.js'; import { LinuxSandboxManager } from '../sandbox/linux/LinuxSandboxManager.js'; import { MacOsSandboxManager } from '../sandbox/macos/MacOsSandboxManager.js'; import { WindowsSandboxManager } from '../sandbox/windows/WindowsSandboxManager.js'; +import type fs from 'node:fs'; + +vi.mock('node:fs/promises', async () => { + const actual = + await vi.importActual( + 'node:fs/promises', + ); + return { + ...actual, + default: { + ...actual, + readdir: vi.fn(), + realpath: vi.fn(), + stat: vi.fn(), + }, + readdir: vi.fn(), + realpath: vi.fn(), + stat: vi.fn(), + }; +}); + +describe('isSecretFile', () => { + it('should return true for .env', () => { + expect(isSecretFile('.env')).toBe(true); + }); + + it('should return true for .env.local', () => { + expect(isSecretFile('.env.local')).toBe(true); + }); + + it('should return true for .env.production', () => { + expect(isSecretFile('.env.production')).toBe(true); + }); + + it('should return false for regular files', () => { + expect(isSecretFile('package.json')).toBe(false); + expect(isSecretFile('index.ts')).toBe(false); + expect(isSecretFile('.gitignore')).toBe(false); + }); + + it('should return false for files starting with .env but not matching pattern', () => { + // This depends on the pattern ".env.*". ".env-backup" would match ".env*" but not ".env.*" + expect(isSecretFile('.env-backup')).toBe(false); + }); +}); + +describe('findSecretFiles', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should find secret files in the root directory', async () => { + vi.mocked(fsPromises.readdir).mockImplementation(((dir: string) => { + if (dir === '/workspace') { + return Promise.resolve([ + { name: '.env', isDirectory: () => false, isFile: () => true }, + { + name: 'package.json', + isDirectory: () => false, + isFile: () => true, + }, + { name: 'src', isDirectory: () => true, isFile: () => false }, + ] as unknown as fs.Dirent[]); + } + return Promise.resolve([] as unknown as fs.Dirent[]); + }) as unknown as typeof fsPromises.readdir); + + const secrets = await findSecretFiles('/workspace'); + expect(secrets).toEqual([path.join('/workspace', '.env')]); + }); + + it('should NOT find secret files recursively (shallow scan only)', async () => { + vi.mocked(fsPromises.readdir).mockImplementation(((dir: string) => { + if (dir === '/workspace') { + return Promise.resolve([ + { name: '.env', isDirectory: () => false, isFile: () => true }, + { name: 'packages', isDirectory: () => true, isFile: () => false }, + ] as unknown as fs.Dirent[]); + } + if (dir === path.join('/workspace', 'packages')) { + return Promise.resolve([ + { name: '.env.local', isDirectory: () => false, isFile: () => true }, + ] as unknown as fs.Dirent[]); + } + return Promise.resolve([] as unknown as fs.Dirent[]); + }) as unknown as typeof fsPromises.readdir); + + const secrets = await findSecretFiles('/workspace'); + expect(secrets).toEqual([path.join('/workspace', '.env')]); + // Should NOT have called readdir for subdirectories + expect(fsPromises.readdir).toHaveBeenCalledTimes(1); + expect(fsPromises.readdir).not.toHaveBeenCalledWith( + path.join('/workspace', 'packages'), + expect.anything(), + ); + }); +}); describe('SandboxManager', () => { afterEach(() => vi.restoreAllMocks()); @@ -48,24 +148,30 @@ describe('SandboxManager', () => { }); it('should return the realpath if the file exists', async () => { - vi.spyOn(fs, 'realpath').mockResolvedValue('/real/path/to/file.txt'); + vi.mocked(fsPromises.realpath).mockResolvedValue( + '/real/path/to/file.txt' as never, + ); const result = await tryRealpath('/some/symlink/to/file.txt'); expect(result).toBe('/real/path/to/file.txt'); - expect(fs.realpath).toHaveBeenCalledWith('/some/symlink/to/file.txt'); + expect(fsPromises.realpath).toHaveBeenCalledWith( + '/some/symlink/to/file.txt', + ); }); it('should fallback to parent directory if file does not exist (ENOENT)', async () => { - vi.spyOn(fs, 'realpath').mockImplementation(async (p) => { + vi.mocked(fsPromises.realpath).mockImplementation(((p: string) => { if (p === '/workspace/nonexistent.txt') { - throw Object.assign(new Error('ENOENT: no such file or directory'), { - code: 'ENOENT', - }); + return Promise.reject( + Object.assign(new Error('ENOENT: no such file or directory'), { + code: 'ENOENT', + }), + ); } if (p === '/workspace') { - return '/real/workspace'; + return Promise.resolve('/real/workspace'); } - throw new Error(`Unexpected path: ${p}`); - }); + return Promise.reject(new Error(`Unexpected path: ${p}`)); + }) as never); const result = await tryRealpath('/workspace/nonexistent.txt'); @@ -74,18 +180,22 @@ describe('SandboxManager', () => { }); it('should recursively fallback up the directory tree on multiple ENOENT errors', async () => { - vi.spyOn(fs, 'realpath').mockImplementation(async (p) => { + vi.mocked(fsPromises.realpath).mockImplementation(((p: string) => { if (p === '/workspace/missing_dir/missing_file.txt') { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + return Promise.reject( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), + ); } if (p === '/workspace/missing_dir') { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + return Promise.reject( + Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), + ); } if (p === '/workspace') { - return '/real/workspace'; + return Promise.resolve('/real/workspace'); } - throw new Error(`Unexpected path: ${p}`); - }); + return Promise.reject(new Error(`Unexpected path: ${p}`)); + }) as never); const result = await tryRealpath( '/workspace/missing_dir/missing_file.txt', @@ -99,20 +209,22 @@ describe('SandboxManager', () => { it('should return the path unchanged if it reaches the root directory and it still does not exist', async () => { const rootPath = path.resolve('/'); - vi.spyOn(fs, 'realpath').mockImplementation(async () => { - throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); - }); + vi.mocked(fsPromises.realpath).mockImplementation(() => + Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })), + ); const result = await tryRealpath(rootPath); expect(result).toBe(rootPath); }); it('should throw an error if realpath fails with a non-ENOENT error (e.g. EACCES)', async () => { - vi.spyOn(fs, 'realpath').mockImplementation(async () => { - throw Object.assign(new Error('EACCES: permission denied'), { - code: 'EACCES', - }); - }); + vi.mocked(fsPromises.realpath).mockImplementation(() => + Promise.reject( + Object.assign(new Error('EACCES: permission denied'), { + code: 'EACCES', + }), + ), + ); await expect(tryRealpath('/secret/file.txt')).rejects.toThrow( 'EACCES: permission denied', diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index ea18e5857d..0028ba9f24 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -21,6 +21,7 @@ import { getSecureSanitizationConfig, type EnvironmentSanitizationConfig, } from './environmentSanitization.js'; + export interface SandboxPermissions { /** Filesystem permissions. */ fileSystem?: { @@ -120,6 +121,87 @@ export const GOVERNANCE_FILES = [ { path: '.git', isDirectory: true }, ] as const; +/** + * Files that contain sensitive secrets or credentials and should be + * completely hidden (deny read/write) in any sandbox. + */ +export const SECRET_FILES = [ + { pattern: '.env' }, + { pattern: '.env.*' }, +] as const; + +/** + * Checks if a given file name matches any of the secret file patterns. + */ +export function isSecretFile(fileName: string): boolean { + return SECRET_FILES.some((s) => { + if (s.pattern.endsWith('*')) { + const prefix = s.pattern.slice(0, -1); + return fileName.startsWith(prefix); + } + return fileName === s.pattern; + }); +} + +/** + * Returns arguments for the Linux 'find' command to locate secret files. + */ +export function getSecretFileFindArgs(): string[] { + const args: string[] = ['(']; + SECRET_FILES.forEach((s, i) => { + if (i > 0) args.push('-o'); + args.push('-name', s.pattern); + }); + args.push(')'); + return args; +} + +/** + * Finds all secret files in a directory up to a certain depth. + * Default is shallow scan (depth 1) for performance. + */ +export async function findSecretFiles( + baseDir: string, + maxDepth = 1, +): Promise { + const secrets: string[] = []; + const skipDirs = new Set([ + 'node_modules', + '.git', + '.venv', + '__pycache__', + 'dist', + 'build', + '.next', + '.idea', + '.vscode', + ]); + + async function walk(dir: string, depth: number) { + if (depth > maxDepth) return; + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (!skipDirs.has(entry.name)) { + await walk(fullPath, depth + 1); + } + } else if (entry.isFile()) { + if (isSecretFile(entry.name)) { + secrets.push(fullPath); + } + } + } + } catch { + // Ignore read errors + } + } + + await walk(baseDir, 1); + return secrets; +} + /** * A no-op implementation of SandboxManager that silently passes commands * through while applying environment sanitization. From d25ce0e143b712d2c509c0a1b8a0019d81e8d3ad Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 26 Mar 2026 17:16:07 -0400 Subject: [PATCH 46/49] fix(core): remove shell outputChunks buffer caching to prevent memory bloat and sanitize prompt input (#23751) --- .../cli/src/ui/hooks/shellCommandProcessor.ts | 18 ++++---- .../src/services/executionLifecycleService.ts | 2 +- .../services/shellExecutionService.test.ts | 10 +--- .../src/services/shellExecutionService.ts | 46 +++++++++++-------- 4 files changed, 37 insertions(+), 39 deletions(-) diff --git a/packages/cli/src/ui/hooks/shellCommandProcessor.ts b/packages/cli/src/ui/hooks/shellCommandProcessor.ts index 7e33d37d1f..3e67ad84b7 100644 --- a/packages/cli/src/ui/hooks/shellCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/shellCommandProcessor.ts @@ -45,20 +45,18 @@ function addShellCommandToGeminiHistory( ? resultText.substring(0, MAX_OUTPUT_LENGTH) + '\n... (truncated)' : resultText; + // Escape backticks to prevent prompt injection breakouts + const safeQuery = rawQuery.replace(/\\/g, '\\\\').replace(/\x60/g, '\\\x60'); + const safeModelContent = modelContent + .replace(/\\/g, '\\\\') + .replace(/\x60/g, '\\\x60'); + // eslint-disable-next-line @typescript-eslint/no-floating-promises geminiClient.addHistory({ role: 'user', parts: [ { - text: `I ran the following shell command: -\`\`\`sh -${rawQuery} -\`\`\` - -This produced the following result: -\`\`\` -${modelContent} -\`\`\``, + text: `I ran the following shell command:\n\`\`\`sh\n${safeQuery}\n\`\`\`\n\nThis produced the following result:\n\`\`\`\n${safeModelContent}\n\`\`\``, }, ], }); @@ -444,7 +442,7 @@ export const useShellCommandProcessor = ( } let mainContent: string; - if (isBinary(result.rawOutput)) { + if (isBinaryStream || isBinary(result.rawOutput)) { mainContent = '[Command produced binary output, which is not shown.]'; } else { diff --git a/packages/core/src/services/executionLifecycleService.ts b/packages/core/src/services/executionLifecycleService.ts index 6df693fccb..5efe26c375 100644 --- a/packages/core/src/services/executionLifecycleService.ts +++ b/packages/core/src/services/executionLifecycleService.ts @@ -16,7 +16,7 @@ export type ExecutionMethod = | 'none'; export interface ExecutionResult { - rawOutput: Buffer; + rawOutput?: Buffer; output: string; exitCode: number | null; signal: number | null; diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index 6a0371b68d..adb519d087 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -880,15 +880,12 @@ describe('ShellExecutionService', () => { const binaryChunk1 = Buffer.from([0x89, 0x50, 0x4e, 0x47]); const binaryChunk2 = Buffer.from([0x0d, 0x0a, 0x1a, 0x0a]); - const { result } = await simulateExecution('cat image.png', (pty) => { + await simulateExecution('cat image.png', (pty) => { pty.onData.mock.calls[0][0](binaryChunk1); pty.onData.mock.calls[0][0](binaryChunk2); pty.onExit.mock.calls[0][0]({ exitCode: 0, signal: null }); }); - expect(result.rawOutput).toEqual( - Buffer.concat([binaryChunk1, binaryChunk2]), - ); expect(onOutputEventMock).toHaveBeenCalledTimes(4); expect(onOutputEventMock.mock.calls[0][0]).toEqual({ type: 'binary_detected', @@ -1464,15 +1461,12 @@ describe('ShellExecutionService child_process fallback', () => { const binaryChunk1 = Buffer.from([0x89, 0x50, 0x4e, 0x47]); const binaryChunk2 = Buffer.from([0x0d, 0x0a, 0x1a, 0x0a]); - const { result } = await simulateExecution('cat image.png', (cp) => { + await simulateExecution('cat image.png', (cp) => { cp.stdout?.emit('data', binaryChunk1); cp.stdout?.emit('data', binaryChunk2); cp.emit('exit', 0, null); }); - expect(result.rawOutput).toEqual( - Buffer.concat([binaryChunk1, binaryChunk2]), - ); expect(onOutputEventMock).toHaveBeenCalledTimes(4); expect(onOutputEventMock.mock.calls[0][0]).toEqual({ type: 'binary_detected', diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts index a5697104ec..6184354a2a 100644 --- a/packages/core/src/services/shellExecutionService.ts +++ b/packages/core/src/services/shellExecutionService.ts @@ -120,7 +120,8 @@ interface ActiveChildProcess { state: { output: string; truncated: boolean; - outputChunks: Buffer[]; + sniffChunks: Buffer[]; + binaryBytesReceived: number; }; } @@ -493,7 +494,8 @@ export class ShellExecutionService { const state = { output: '', truncated: false, - outputChunks: [] as Buffer[], + sniffChunks: [] as Buffer[], + binaryBytesReceived: 0, }; if (child.pid) { @@ -563,14 +565,19 @@ export class ShellExecutionService { } } - state.outputChunks.push(data); + if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) { + state.sniffChunks.push(data); + } else if (!isStreamingRawContent) { + state.binaryBytesReceived += data.length; + } if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) { - const sniffBuffer = Buffer.concat(state.outputChunks.slice(0, 20)); + const sniffBuffer = Buffer.concat(state.sniffChunks.slice(0, 20)); sniffedBytes = sniffBuffer.length; if (isBinary(sniffBuffer)) { isStreamingRawContent = false; + state.binaryBytesReceived = sniffBuffer.length; const event: ShellOutputEvent = { type: 'binary_detected' }; onOutputEvent(event); if (child.pid) { @@ -610,10 +617,7 @@ export class ShellExecutionService { } } } else { - const totalBytes = state.outputChunks.reduce( - (sum, chunk) => sum + chunk.length, - 0, - ); + const totalBytes = state.binaryBytesReceived; const event: ShellOutputEvent = { type: 'binary_progress', bytesReceived: totalBytes, @@ -629,7 +633,7 @@ export class ShellExecutionService { code: number | null, signal: NodeJS.Signals | null, ) => { - const { finalBuffer } = cleanup(); + cleanup(); let combinedOutput = state.output; if (state.truncated) { @@ -644,7 +648,7 @@ export class ShellExecutionService { const exitSignal = signal ? os.constants.signals[signal] : null; const resultPayload: ShellExecutionResult = { - rawOutput: finalBuffer, + rawOutput: Buffer.from(''), output: finalStrippedOutput, exitCode, signal: exitSignal, @@ -733,8 +737,7 @@ export class ShellExecutionService { } } - const finalBuffer = Buffer.concat(state.outputChunks); - return { finalBuffer }; + return; } return { pid: child.pid, result }; @@ -864,7 +867,8 @@ export class ShellExecutionService { let processingChain = Promise.resolve(); let decoder: TextDecoder | null = null; let output: string | AnsiOutput | null = null; - const outputChunks: Buffer[] = []; + const sniffChunks: Buffer[] = []; + let binaryBytesReceived = 0; const error: Error | null = null; let exited = false; @@ -995,14 +999,19 @@ export class ShellExecutionService { } } - outputChunks.push(data); + if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) { + sniffChunks.push(data); + } else if (!isStreamingRawContent) { + binaryBytesReceived += data.length; + } if (isStreamingRawContent && sniffedBytes < MAX_SNIFF_SIZE) { - const sniffBuffer = Buffer.concat(outputChunks.slice(0, 20)); + const sniffBuffer = Buffer.concat(sniffChunks.slice(0, 20)); sniffedBytes = sniffBuffer.length; if (isBinary(sniffBuffer)) { isStreamingRawContent = false; + binaryBytesReceived = sniffBuffer.length; const event: ShellOutputEvent = { type: 'binary_detected' }; onOutputEvent(event); ExecutionLifecycleService.emitEvent(ptyPid, event); @@ -1027,10 +1036,7 @@ export class ShellExecutionService { resolveChunk(); }); } else { - const totalBytes = outputChunks.reduce( - (sum, chunk) => sum + chunk.length, - 0, - ); + const totalBytes = binaryBytesReceived; const event: ShellOutputEvent = { type: 'binary_progress', bytesReceived: totalBytes, @@ -1076,7 +1082,7 @@ export class ShellExecutionService { }); ExecutionLifecycleService.completeWithResult(ptyPid, { - rawOutput: Buffer.concat(outputChunks), + rawOutput: Buffer.from(''), output: getFullBufferText(headlessTerminal), exitCode, signal: signal ?? null, From 73dd7328df0962dc23cc15aa6828677b16bddfb0 Mon Sep 17 00:00:00 2001 From: Aditya Bijalwan Date: Fri, 27 Mar 2026 03:03:37 +0530 Subject: [PATCH 47/49] feat(core): implement persistent browser session management (#21306) Co-authored-by: Gaurav <39389231+gsquared94@users.noreply.github.com> Co-authored-by: cynthialong0-0 <82900738+cynthialong0-0@users.noreply.github.com> --- packages/cli/src/ui/commands/clearCommand.ts | 5 + packages/cli/src/utils/cleanup.ts | 8 + .../browser/browserAgentFactory.test.ts | 64 ++++---- .../src/agents/browser/browserAgentFactory.ts | 21 +-- .../browser/browserAgentInvocation.test.ts | 17 ++- .../agents/browser/browserAgentInvocation.ts | 8 +- .../src/agents/browser/browserManager.test.ts | 137 +++++++++++++++++- .../core/src/agents/browser/browserManager.ts | 131 ++++++++++++++++- packages/core/src/index.ts | 2 + 9 files changed, 332 insertions(+), 61 deletions(-) diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index 061c4f9085..fb032da811 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -9,6 +9,7 @@ import { SessionEndReason, SessionStartSource, flushTelemetry, + resetBrowserSession, } from '@google/gemini-cli-core'; import { CommandKind, type SlashCommand } from './types.js'; import { MessageType } from '../types.js'; @@ -43,6 +44,10 @@ export const clearCommand: SlashCommand = { if (geminiClient) { context.ui.setDebugMessage('Clearing terminal and resetting chat.'); + + // Close persistent browser sessions before resetting chat + await resetBrowserSession(); + // If resetChat fails, the exception will propagate and halt the command, // which is the correct behavior to signal a failure to the user. await geminiClient.resetChat(); diff --git a/packages/cli/src/utils/cleanup.ts b/packages/cli/src/utils/cleanup.ts index 19aa795640..abdcabae5a 100644 --- a/packages/cli/src/utils/cleanup.ts +++ b/packages/cli/src/utils/cleanup.ts @@ -11,6 +11,7 @@ import { shutdownTelemetry, isTelemetrySdkInitialized, ExitCodes, + resetBrowserSession, } from '@google/gemini-cli-core'; import type { Config } from '@google/gemini-cli-core'; @@ -72,6 +73,13 @@ export async function runExitCleanup() { } cleanupFunctions.length = 0; // Clear the array + // Close persistent browser sessions before disposing config + try { + await resetBrowserSession(); + } catch (_) { + // Ignore errors during browser cleanup + } + if (configForTelemetry) { try { await configForTelemetry.dispose(); diff --git a/packages/core/src/agents/browser/browserAgentFactory.test.ts b/packages/core/src/agents/browser/browserAgentFactory.test.ts index 003ba465c4..22a99edab2 100644 --- a/packages/core/src/agents/browser/browserAgentFactory.test.ts +++ b/packages/core/src/agents/browser/browserAgentFactory.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { createBrowserAgentDefinition, - cleanupBrowserAgent, + resetBrowserSession, } from './browserAgentFactory.js'; import { injectAutomationOverlay } from './automationOverlay.js'; import { makeFakeConfig } from '../../test-utils/config.js'; @@ -15,7 +15,6 @@ import { PolicyDecision, PRIORITY_SUBAGENT_TOOL } from '../../policy/types.js'; import type { Config } from '../../config/config.js'; import type { MessageBus } from '../../confirmation-bus/message-bus.js'; import type { PolicyEngine } from '../../policy/policy-engine.js'; -import type { BrowserManager } from './browserManager.js'; // Create mock browser manager const mockBrowserManager = { @@ -35,9 +34,17 @@ const mockBrowserManager = { }; // Mock dependencies -vi.mock('./browserManager.js', () => ({ - BrowserManager: vi.fn(() => mockBrowserManager), -})); +vi.mock('./browserManager.js', () => { + const instancesMap = new Map(); + const MockBrowserManager = vi.fn() as unknown as Record; + // Add static methods — use mockImplementation for lazy eval (hoisting-safe) + MockBrowserManager['getInstance'] = vi.fn(); + MockBrowserManager['resetAll'] = vi.fn().mockResolvedValue(undefined); + MockBrowserManager['instances'] = instancesMap; + return { + BrowserManager: MockBrowserManager, + }; +}); vi.mock('./automationOverlay.js', () => ({ injectAutomationOverlay: vi.fn().mockResolvedValue(undefined), @@ -60,9 +67,16 @@ describe('browserAgentFactory', () => { let mockConfig: Config; let mockMessageBus: MessageBus; - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); + // Set up getInstance to return mockBrowserManager + // (Can't do this in vi.mock factory due to hoisting) + const { BrowserManager: MockBM } = await import('./browserManager.js'); + (MockBM as unknown as Record>)[ + 'getInstance' + ].mockReturnValue(mockBrowserManager); + vi.mocked(injectAutomationOverlay).mockClear(); // Reset mock implementations @@ -99,7 +113,7 @@ describe('browserAgentFactory', () => { } as unknown as MessageBus; }); - afterEach(() => { + afterEach(async () => { vi.restoreAllMocks(); }); @@ -302,6 +316,23 @@ describe('browserAgentFactory', () => { }); }); + describe('resetBrowserSession', () => { + it('should delegate to BrowserManager.resetAll', async () => { + const { BrowserManager: MockBrowserManager } = await import( + './browserManager.js' + ); + await resetBrowserSession(); + expect( + ( + MockBrowserManager as unknown as Record< + string, + ReturnType + > + )['resetAll'], + ).toHaveBeenCalled(); + }); + }); + describe('Policy Registration', () => { let mockPolicyEngine: { addRule: ReturnType; @@ -421,25 +452,6 @@ describe('browserAgentFactory', () => { ); }); }); - - describe('cleanupBrowserAgent', () => { - it('should call close on browser manager', async () => { - await cleanupBrowserAgent( - mockBrowserManager as unknown as BrowserManager, - ); - - expect(mockBrowserManager.close).toHaveBeenCalled(); - }); - - it('should handle errors during cleanup gracefully', async () => { - const errorManager = { - close: vi.fn().mockRejectedValue(new Error('Close failed')), - } as unknown as BrowserManager; - - // Should not throw - await expect(cleanupBrowserAgent(errorManager)).resolves.toBeUndefined(); - }); - }); }); describe('buildBrowserSystemPrompt', () => { diff --git a/packages/core/src/agents/browser/browserAgentFactory.ts b/packages/core/src/agents/browser/browserAgentFactory.ts index 0d28651c12..94632354d7 100644 --- a/packages/core/src/agents/browser/browserAgentFactory.ts +++ b/packages/core/src/agents/browser/browserAgentFactory.ts @@ -62,8 +62,8 @@ export async function createBrowserAgentDefinition( 'Creating browser agent definition with isolated MCP tools...', ); - // Create and initialize browser manager with isolated MCP client - const browserManager = new BrowserManager(config); + // Get or create browser manager singleton for this session mode/profile + const browserManager = BrowserManager.getInstance(config); await browserManager.ensureConnection(); if (printOutput) { @@ -242,19 +242,10 @@ export async function createBrowserAgentDefinition( } /** - * Cleans up browser resources after agent execution. + * Closes all persistent browser sessions and cleans up resources. * - * @param browserManager The browser manager to clean up + * Call this on /clear commands and CLI exit to reset browser state. */ -export async function cleanupBrowserAgent( - browserManager: BrowserManager, -): Promise { - try { - await browserManager.close(); - debugLogger.log('Browser agent cleanup complete'); - } catch (error) { - debugLogger.error( - `Error during browser cleanup: ${error instanceof Error ? error.message : String(error)}`, - ); - } +export async function resetBrowserSession(): Promise { + await BrowserManager.resetAll(); } diff --git a/packages/core/src/agents/browser/browserAgentInvocation.test.ts b/packages/core/src/agents/browser/browserAgentInvocation.test.ts index e41377bdd4..200f04e67b 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.test.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.test.ts @@ -26,7 +26,10 @@ vi.mock('../../utils/debugLogger.js', () => ({ vi.mock('./browserAgentFactory.js', () => ({ createBrowserAgentDefinition: vi.fn(), - cleanupBrowserAgent: vi.fn(), +})); + +vi.mock('./inputBlocker.js', () => ({ + removeInputBlocker: vi.fn(), })); vi.mock('../local-executor.js', () => ({ @@ -35,10 +38,8 @@ vi.mock('../local-executor.js', () => ({ }, })); -import { - createBrowserAgentDefinition, - cleanupBrowserAgent, -} from './browserAgentFactory.js'; +import { createBrowserAgentDefinition } from './browserAgentFactory.js'; +import { removeInputBlocker } from './inputBlocker.js'; import { LocalAgentExecutor } from '../local-executor.js'; import type { ToolLiveOutput } from '../../tools/tools.js'; @@ -190,7 +191,7 @@ describe('BrowserAgentInvocation', () => { vi.mocked(LocalAgentExecutor.create).mockResolvedValue( mockExecutor as never, ); - vi.mocked(cleanupBrowserAgent).mockClear(); + vi.mocked(removeInputBlocker).mockClear(); }); it('should return result text and call cleanup on success', async () => { @@ -209,7 +210,7 @@ describe('BrowserAgentInvocation', () => { expect((result.llmContent as Array<{ text: string }>)[0].text).toContain( 'Browser agent finished', ); - expect(cleanupBrowserAgent).toHaveBeenCalled(); + expect(removeInputBlocker).toHaveBeenCalled(); }); it('should work without updateOutput (fire-and-forget)', async () => { @@ -239,7 +240,7 @@ describe('BrowserAgentInvocation', () => { const result = await invocation.execute(controller.signal); expect(result.error).toBeDefined(); - expect(cleanupBrowserAgent).toHaveBeenCalled(); + expect(removeInputBlocker).toHaveBeenCalled(); }); // ─── Structured SubagentProgress emission tests ─────────────────────── diff --git a/packages/core/src/agents/browser/browserAgentInvocation.ts b/packages/core/src/agents/browser/browserAgentInvocation.ts index 0c96e1894c..586baf7d5a 100644 --- a/packages/core/src/agents/browser/browserAgentInvocation.ts +++ b/packages/core/src/agents/browser/browserAgentInvocation.ts @@ -33,10 +33,7 @@ import { isToolActivityError, } from '../types.js'; import type { MessageBus } from '../../confirmation-bus/message-bus.js'; -import { - createBrowserAgentDefinition, - cleanupBrowserAgent, -} from './browserAgentFactory.js'; +import { createBrowserAgentDefinition } from './browserAgentFactory.js'; import { removeInputBlocker } from './inputBlocker.js'; import { sanitizeThoughtContent, @@ -368,10 +365,9 @@ ${displayResult} }, }; } finally { - // Always cleanup browser resources + // Clean up input blocker, but keep browserManager alive for persistent sessions if (browserManager) { await removeInputBlocker(browserManager); - await cleanupBrowserAgent(browserManager); } } } diff --git a/packages/core/src/agents/browser/browserManager.test.ts b/packages/core/src/agents/browser/browserManager.test.ts index a326164c43..9813fd721f 100644 --- a/packages/core/src/agents/browser/browserManager.test.ts +++ b/packages/core/src/agents/browser/browserManager.test.ts @@ -127,8 +127,10 @@ describe('BrowserManager', () => { ); }); - afterEach(() => { + afterEach(async () => { vi.restoreAllMocks(); + // Clear singleton cache to avoid cross-test leakage + await BrowserManager.resetAll(); }); describe('MCP bundled path resolution', () => { @@ -700,6 +702,137 @@ describe('BrowserManager', () => { }); }); + describe('getInstance', () => { + it('should return the same instance for the same session mode', () => { + const instance1 = BrowserManager.getInstance(mockConfig); + const instance2 = BrowserManager.getInstance(mockConfig); + + expect(instance1).toBe(instance2); + }); + + it('should return different instances for different session modes', () => { + const isolatedConfig = makeFakeConfig({ + agents: { + overrides: { browser_agent: { enabled: true } }, + browser: { sessionMode: 'isolated' }, + }, + }); + + const instance1 = BrowserManager.getInstance(mockConfig); + const instance2 = BrowserManager.getInstance(isolatedConfig); + + expect(instance1).not.toBe(instance2); + }); + + it('should return different instances for different profile paths', () => { + const config1 = makeFakeConfig({ + agents: { + overrides: { browser_agent: { enabled: true } }, + browser: { profilePath: '/path/a' }, + }, + }); + const config2 = makeFakeConfig({ + agents: { + overrides: { browser_agent: { enabled: true } }, + browser: { profilePath: '/path/b' }, + }, + }); + + const instance1 = BrowserManager.getInstance(config1); + const instance2 = BrowserManager.getInstance(config2); + + expect(instance1).not.toBe(instance2); + }); + }); + + describe('resetAll', () => { + it('should close all instances and clear the cache', async () => { + const instance1 = BrowserManager.getInstance(mockConfig); + await instance1.ensureConnection(); + + const isolatedConfig = makeFakeConfig({ + agents: { + overrides: { browser_agent: { enabled: true } }, + browser: { sessionMode: 'isolated' }, + }, + }); + const instance2 = BrowserManager.getInstance(isolatedConfig); + await instance2.ensureConnection(); + + await BrowserManager.resetAll(); + + // After resetAll, getInstance should return new instances + const instance3 = BrowserManager.getInstance(mockConfig); + expect(instance3).not.toBe(instance1); + }); + + it('should handle errors during cleanup gracefully', async () => { + const instance = BrowserManager.getInstance(mockConfig); + await instance.ensureConnection(); + + // Make close throw by overriding the client's close method + const client = await instance.getRawMcpClient(); + vi.mocked(client.close).mockRejectedValueOnce(new Error('close failed')); + + // Should not throw + await expect(BrowserManager.resetAll()).resolves.toBeUndefined(); + }); + }); + + describe('isConnected', () => { + it('should return false before connection', () => { + const manager = new BrowserManager(mockConfig); + expect(manager.isConnected()).toBe(false); + }); + + it('should return true after successful connection', async () => { + const manager = new BrowserManager(mockConfig); + await manager.ensureConnection(); + expect(manager.isConnected()).toBe(true); + }); + + it('should return false after close', async () => { + const manager = new BrowserManager(mockConfig); + await manager.ensureConnection(); + await manager.close(); + expect(manager.isConnected()).toBe(false); + }); + }); + + describe('reconnection', () => { + it('should reconnect after unexpected disconnect', async () => { + const manager = new BrowserManager(mockConfig); + await manager.ensureConnection(); + + // Simulate transport closing unexpectedly via the onclose callback + const transportInstance = + vi.mocked(StdioClientTransport).mock.results[0]?.value; + if (transportInstance?.onclose) { + transportInstance.onclose(); + } + + // Manager should recognize disconnection + expect(manager.isConnected()).toBe(false); + + // ensureConnection should reconnect + await manager.ensureConnection(); + expect(manager.isConnected()).toBe(true); + }); + }); + + describe('concurrency', () => { + it('should not call connectMcp twice when ensureConnection is called concurrently', async () => { + const manager = new BrowserManager(mockConfig); + + // Call ensureConnection twice simultaneously without awaiting the first + const [p1, p2] = [manager.ensureConnection(), manager.ensureConnection()]; + await Promise.all([p1, p2]); + + // connectMcp (via StdioClientTransport constructor) should only have been called once + // Each connection attempt creates a new StdioClientTransport + }); + }); + describe('overlay re-injection in callTool', () => { it('should re-inject overlay and input blocker after click in non-headless mode when input disabling is enabled', async () => { // Enable input disabling in config @@ -822,8 +955,6 @@ describe('BrowserManager', () => { const manager = new BrowserManager(mockConfig); await manager.callTool('click', { uid: 'bad' }); - - expect(injectAutomationOverlay).not.toHaveBeenCalled(); }); }); diff --git a/packages/core/src/agents/browser/browserManager.ts b/packages/core/src/agents/browser/browserManager.ts index 90de6b99fc..81f9db8250 100644 --- a/packages/core/src/agents/browser/browserManager.ts +++ b/packages/core/src/agents/browser/browserManager.ts @@ -40,6 +40,12 @@ const BROWSER_PROFILE_DIR = 'cli-browser-profile'; // Default timeout for MCP operations const MCP_TIMEOUT_MS = 60_000; +// Maximum reconnection attempts before giving up +const MAX_RECONNECT_RETRIES = 3; + +// Base delay (ms) for exponential backoff between reconnection attempts +const RECONNECT_BASE_DELAY_MS = 500; + /** * Tools that can cause a full-page navigation (explicitly or implicitly). * @@ -92,10 +98,73 @@ export interface McpToolCallResult { * in the main ToolRegistry. Tools are kept local to the browser agent. */ export class BrowserManager { + // --- Static singleton management --- + private static instances = new Map(); + + /** + * Returns the cache key for a given config. + * Uses `sessionMode:profilePath` so different profiles get separate instances. + */ + private static getInstanceKey(config: Config): string { + const browserConfig = config.getBrowserAgentConfig(); + const sessionMode = browserConfig.customConfig.sessionMode ?? 'persistent'; + const profilePath = browserConfig.customConfig.profilePath ?? 'default'; + return `${sessionMode}:${profilePath}`; + } + + /** + * Returns an existing BrowserManager for the current config's session mode + * and profile, or creates a new one. + */ + static getInstance(config: Config): BrowserManager { + const key = BrowserManager.getInstanceKey(config); + let instance = BrowserManager.instances.get(key); + if (!instance) { + instance = new BrowserManager(config); + BrowserManager.instances.set(key, instance); + debugLogger.log(`Created new BrowserManager singleton (key: ${key})`); + } else { + debugLogger.log( + `Reusing existing BrowserManager singleton (key: ${key})`, + ); + } + return instance; + } + + /** + * Closes all cached BrowserManager instances and clears the cache. + * Called on /clear commands and CLI exit. + */ + static async resetAll(): Promise { + const results = await Promise.allSettled( + Array.from(BrowserManager.instances.values()).map((instance) => + instance.close(), + ), + ); + for (const result of results) { + if (result.status === 'rejected') { + debugLogger.error( + `Error during BrowserManager cleanup: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}`, + ); + } + } + BrowserManager.instances.clear(); + } + + /** + * Alias for resetAll — used by CLI exit cleanup for clarity. + */ + static async closeAll(): Promise { + await BrowserManager.resetAll(); + } + + // --- Instance state --- // Raw MCP SDK Client - NOT the wrapper McpClient private rawMcpClient: Client | undefined; private mcpTransport: StdioClientTransport | undefined; private discoveredTools: McpTool[] = []; + private disconnected = false; + private connectionPromise: Promise | undefined; /** State for action rate limiting */ private actionCounter = 0; @@ -266,14 +335,53 @@ export class BrowserManager { }; } + /** + * Returns whether the MCP client is currently connected and healthy. + */ + isConnected(): boolean { + return this.rawMcpClient !== undefined && !this.disconnected; + } + /** * Ensures browser and MCP client are connected. + * If a previous connection was lost (e.g., user closed the browser), + * this will reconnect with exponential backoff (up to MAX_RECONNECT_RETRIES). + * + * Concurrent callers share a single in-flight connection promise so that + * two subagents racing at startup do not trigger duplicate connectMcp() calls. */ async ensureConnection(): Promise { - if (this.rawMcpClient) { + // Already connected and healthy — nothing to do + if (this.rawMcpClient && !this.disconnected) { return; } + // A connection is already being established — wait for it instead of racing + if (this.connectionPromise) { + return this.connectionPromise; + } + + // If previously connected but transport died, clean up before reconnecting + if (this.disconnected) { + debugLogger.log( + 'Previous browser connection was lost. Cleaning up before reconnecting...', + ); + await this.close(); + this.disconnected = false; + } + + // Start connecting; store the promise so concurrent callers can join it + this.connectionPromise = this.connectWithRetry().finally(() => { + this.connectionPromise = undefined; + }); + + return this.connectionPromise; + } + + /** + * Connects to chrome-devtools-mcp with exponential backoff retry. + */ + private async connectWithRetry(): Promise { // Request browser consent if needed (first-run privacy notice) const consentGranted = await getBrowserConsentIfNeeded(); if (!consentGranted) { @@ -283,7 +391,23 @@ export class BrowserManager { ); } - await this.connectMcp(); + let lastError: Error | undefined; + for (let attempt = 0; attempt < MAX_RECONNECT_RETRIES; attempt++) { + try { + await this.connectMcp(); + return; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + if (attempt < MAX_RECONNECT_RETRIES - 1) { + const delay = RECONNECT_BASE_DELAY_MS * Math.pow(2, attempt); + debugLogger.log( + `Connection attempt ${attempt + 1} failed, retrying in ${delay}ms...`, + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + throw lastError!; } /** @@ -317,6 +441,7 @@ export class BrowserManager { } this.discoveredTools = []; + this.connectionPromise = undefined; } /** @@ -442,7 +567,7 @@ export class BrowserManager { 'chrome-devtools-mcp transport closed unexpectedly. ' + 'The MCP server process may have crashed.', ); - this.rawMcpClient = undefined; + this.disconnected = true; }; this.mcpTransport.onerror = (error: Error) => { debugLogger.error( diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2d48eeffe9..09ea05871a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -184,6 +184,8 @@ export * from './agents/agentLoader.js'; export * from './agents/local-executor.js'; export * from './agents/agent-scheduler.js'; +// Export browser session management +export { resetBrowserSession } from './agents/browser/browserAgentFactory.js'; // Export agent session interface export * from './agent/agent-session.js'; export * from './agent/legacy-agent-session.js'; From 8868b34c752a965fa2fd3639cc7b5abe78cfe658 Mon Sep 17 00:00:00 2001 From: Tommaso Sciortino Date: Thu, 26 Mar 2026 15:10:15 -0700 Subject: [PATCH 48/49] refactor(core): delegate sandbox denial parsing to SandboxManager (#23928) --- .../core/src/policy/policy-engine.test.ts | 1 + .../src/sandbox/linux/LinuxSandboxManager.ts | 7 + .../src/sandbox/macos/MacOsSandboxManager.ts | 7 + .../sandbox/utils/sandboxDenialUtils.test.ts | 43 ++++ .../src/sandbox/utils/sandboxDenialUtils.ts | 81 ++++++ .../sandbox/windows/WindowsSandboxManager.ts | 6 + packages/core/src/services/sandboxManager.ts | 27 +- .../sandboxedFileSystemService.test.ts | 4 + .../services/shellExecutionService.test.ts | 1 + packages/core/src/tools/shell.ts | 243 +++++++----------- 10 files changed, 272 insertions(+), 148 deletions(-) create mode 100644 packages/core/src/sandbox/utils/sandboxDenialUtils.test.ts create mode 100644 packages/core/src/sandbox/utils/sandboxDenialUtils.ts diff --git a/packages/core/src/policy/policy-engine.test.ts b/packages/core/src/policy/policy-engine.test.ts index 95f754bc02..5bbe62fec9 100644 --- a/packages/core/src/policy/policy-engine.test.ts +++ b/packages/core/src/policy/policy-engine.test.ts @@ -375,6 +375,7 @@ describe('PolicyEngine', () => { isKnownSafeCommand: vi .fn() .mockImplementation((args) => args[0] === 'npm'), + parseDenials: vi.fn().mockReturnValue(undefined), } as unknown as SandboxManager; engine = new PolicyEngine({ diff --git a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts index 28be7ad281..7f9ff599a7 100644 --- a/packages/core/src/sandbox/linux/LinuxSandboxManager.ts +++ b/packages/core/src/sandbox/linux/LinuxSandboxManager.ts @@ -16,7 +16,9 @@ import { GOVERNANCE_FILES, getSecretFileFindArgs, sanitizePaths, + type ParsedSandboxDenial, } from '../../services/sandboxManager.js'; +import type { ShellExecutionResult } from '../../services/shellExecutionService.js'; import { sanitizeEnvironment, getSecureSanitizationConfig, @@ -38,6 +40,7 @@ import { isKnownSafeCommand, isDangerousCommand, } from '../utils/commandSafety.js'; +import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js'; let cachedBpfPath: string | undefined; @@ -154,6 +157,10 @@ export class LinuxSandboxManager implements SandboxManager { return isDangerousCommand(args); } + parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined { + return parsePosixSandboxDenials(result); + } + private getMaskFilePath(): string { if ( LinuxSandboxManager.maskFilePath && diff --git a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts index db2768d7c6..2d7c7daf8b 100644 --- a/packages/core/src/sandbox/macos/MacOsSandboxManager.ts +++ b/packages/core/src/sandbox/macos/MacOsSandboxManager.ts @@ -10,7 +10,9 @@ import { type SandboxedCommand, type SandboxPermissions, type GlobalSandboxOptions, + type ParsedSandboxDenial, } from '../../services/sandboxManager.js'; +import type { ShellExecutionResult } from '../../services/shellExecutionService.js'; import { sanitizeEnvironment, getSecureSanitizationConfig, @@ -27,6 +29,7 @@ import { } from '../utils/commandSafety.js'; import { type SandboxPolicyManager } from '../../policy/sandboxPolicyManager.js'; import { verifySandboxOverrides } from '../utils/commandUtils.js'; +import { parsePosixSandboxDenials } from '../utils/sandboxDenialUtils.js'; export interface MacOsSandboxOptions extends GlobalSandboxOptions { /** The current sandbox mode behavior from config. */ @@ -59,6 +62,10 @@ export class MacOsSandboxManager implements SandboxManager { return isDangerousCommand(args); } + parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined { + return parsePosixSandboxDenials(result); + } + async prepareCommand(req: SandboxRequest): Promise { await initializeShellParsers(); const sanitizationConfig = getSecureSanitizationConfig( diff --git a/packages/core/src/sandbox/utils/sandboxDenialUtils.test.ts b/packages/core/src/sandbox/utils/sandboxDenialUtils.test.ts new file mode 100644 index 0000000000..3b4585ba69 --- /dev/null +++ b/packages/core/src/sandbox/utils/sandboxDenialUtils.test.ts @@ -0,0 +1,43 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { parsePosixSandboxDenials } from './sandboxDenialUtils.js'; +import type { ShellExecutionResult } from '../../services/shellExecutionService.js'; + +describe('parsePosixSandboxDenials', () => { + it('should detect file system denial and extract paths', () => { + const parsed = parsePosixSandboxDenials({ + output: 'ls: /root: Operation not permitted', + } as unknown as ShellExecutionResult); + expect(parsed).toBeDefined(); + expect(parsed?.filePaths).toContain('/root'); + }); + + it('should detect network denial', () => { + const parsed = parsePosixSandboxDenials({ + output: 'curl: (6) Could not resolve host: google.com', + } as unknown as ShellExecutionResult); + expect(parsed).toBeDefined(); + expect(parsed?.network).toBe(true); + }); + + it('should use fallback heuristic for absolute paths', () => { + const parsed = parsePosixSandboxDenials({ + output: + 'operation not permitted\nsome error happened with /some/path/to/file', + } as unknown as ShellExecutionResult); + expect(parsed).toBeDefined(); + expect(parsed?.filePaths).toContain('/some/path/to/file'); + }); + + it('should return undefined if no denial detected', () => { + const parsed = parsePosixSandboxDenials({ + output: 'hello world', + } as unknown as ShellExecutionResult); + expect(parsed).toBeUndefined(); + }); +}); diff --git a/packages/core/src/sandbox/utils/sandboxDenialUtils.ts b/packages/core/src/sandbox/utils/sandboxDenialUtils.ts new file mode 100644 index 0000000000..d1e2366e76 --- /dev/null +++ b/packages/core/src/sandbox/utils/sandboxDenialUtils.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { type ParsedSandboxDenial } from '../../services/sandboxManager.js'; +import type { ShellExecutionResult } from '../../services/shellExecutionService.js'; + +/** + * Common POSIX-style sandbox denial detection. + * Used by macOS and Linux sandbox managers. + */ +export function parsePosixSandboxDenials( + result: ShellExecutionResult, +): ParsedSandboxDenial | undefined { + const output = result.output || ''; + const errorOutput = result.error?.message; + const combined = (output + ' ' + (errorOutput || '')).toLowerCase(); + + const isFileDenial = [ + 'operation not permitted', + 'vim:e303', + 'should be read/write', + 'sandbox_apply', + 'sandbox: ', + ].some((keyword) => combined.includes(keyword)); + + const isNetworkDenial = [ + 'error connecting to', + 'network is unreachable', + 'could not resolve host', + 'connection refused', + 'no address associated with hostname', + ].some((keyword) => combined.includes(keyword)); + + if (!isFileDenial && !isNetworkDenial) { + return undefined; + } + + const filePaths = new Set(); + + // Extract denied paths (POSIX absolute paths) + const regex = + /(?:^|\s)['"]?(\/[\w.-/]+)['"]?:\s*[Oo]peration not permitted/gi; + let match; + while ((match = regex.exec(output)) !== null) { + filePaths.add(match[1]); + } + if (errorOutput) { + while ((match = regex.exec(errorOutput)) !== null) { + filePaths.add(match[1]); + } + } + + // Fallback heuristic: look for any absolute path in the output if it was a file denial + if (isFileDenial && filePaths.size === 0) { + const fallbackRegex = + /(?:^|[\s"'[\]])(\/[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)+)(?:$|[\s"'[\]:])/gi; + let m; + while ((m = fallbackRegex.exec(output)) !== null) { + const p = m[1]; + if (p && !p.startsWith('/bin/') && !p.startsWith('/usr/bin/')) { + filePaths.add(p); + } + } + if (errorOutput) { + while ((m = fallbackRegex.exec(errorOutput)) !== null) { + const p = m[1]; + if (p && !p.startsWith('/bin/') && !p.startsWith('/usr/bin/')) { + filePaths.add(p); + } + } + } + } + + return { + network: isNetworkDenial || undefined, + filePaths: filePaths.size > 0 ? Array.from(filePaths) : undefined, + }; +} diff --git a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts index a07241366a..d1770b094f 100644 --- a/packages/core/src/sandbox/windows/WindowsSandboxManager.ts +++ b/packages/core/src/sandbox/windows/WindowsSandboxManager.ts @@ -18,7 +18,9 @@ import { sanitizePaths, tryRealpath, type SandboxPermissions, + type ParsedSandboxDenial, } from '../../services/sandboxManager.js'; +import type { ShellExecutionResult } from '../../services/shellExecutionService.js'; import { sanitizeEnvironment, getSecureSanitizationConfig, @@ -77,6 +79,10 @@ export class WindowsSandboxManager implements SandboxManager { return isDangerousCommand(args); } + parseDenials(_result: ShellExecutionResult): ParsedSandboxDenial | undefined { + return undefined; // TODO: Implement Windows-specific denial parsing + } + /** * Ensures a file or directory exists. */ diff --git a/packages/core/src/services/sandboxManager.ts b/packages/core/src/services/sandboxManager.ts index 0028ba9f24..41b0ab045d 100644 --- a/packages/core/src/services/sandboxManager.ts +++ b/packages/core/src/services/sandboxManager.ts @@ -21,7 +21,7 @@ import { getSecureSanitizationConfig, type EnvironmentSanitizationConfig, } from './environmentSanitization.js'; - +import type { ShellExecutionResult } from './shellExecutionService.js'; export interface SandboxPermissions { /** Filesystem permissions. */ fileSystem?: { @@ -91,6 +91,16 @@ export interface SandboxedCommand { cwd?: string; } +/** + * A structured result from parsing sandbox denials. + */ +export interface ParsedSandboxDenial { + /** If the denial is related to file system access, these are the paths that were blocked. */ + filePaths?: string[]; + /** If the denial is related to network access. */ + network?: boolean; +} + /** * Interface for a service that prepares commands for sandboxed execution. */ @@ -109,6 +119,11 @@ export interface SandboxManager { * Checks if a command with its arguments is explicitly known to be dangerous for this sandbox. */ isDangerousCommand(args: string[]): boolean; + + /** + * Parses the output of a command to detect sandbox denials. + */ + parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined; } /** @@ -236,10 +251,14 @@ export class NoopSandboxManager implements SandboxManager { ? isWindowsDangerousCommand(args) : isMacDangerousCommand(args); } + + parseDenials(): undefined { + return undefined; + } } /** - * SandboxManager that implements actual sandboxing. + * A SandboxManager implementation that just runs locally (no sandboxing yet). */ export class LocalSandboxManager implements SandboxManager { async prepareCommand(_req: SandboxRequest): Promise { @@ -253,6 +272,10 @@ export class LocalSandboxManager implements SandboxManager { isDangerousCommand(_args: string[]): boolean { return false; } + + parseDenials(): undefined { + return undefined; + } } /** diff --git a/packages/core/src/services/sandboxedFileSystemService.test.ts b/packages/core/src/services/sandboxedFileSystemService.test.ts index 046aadb132..1070af54d3 100644 --- a/packages/core/src/services/sandboxedFileSystemService.test.ts +++ b/packages/core/src/services/sandboxedFileSystemService.test.ts @@ -43,6 +43,10 @@ class MockSandboxManager implements SandboxManager { isDangerousCommand(): boolean { return false; } + + parseDenials(): undefined { + return undefined; + } } describe('SandboxedFileSystemService', () => { diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts index adb519d087..465d79fe4b 100644 --- a/packages/core/src/services/shellExecutionService.test.ts +++ b/packages/core/src/services/shellExecutionService.test.ts @@ -1914,6 +1914,7 @@ describe('ShellExecutionService environment variables', () => { }), isKnownSafeCommand: vi.fn().mockReturnValue(false), isDangerousCommand: vi.fn().mockReturnValue(false), + parseDenials: vi.fn().mockReturnValue(undefined), }; const configWithSandbox: ShellExecutionConfig = { diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index f72b6f28fe..0b4760ccc7 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -478,162 +478,113 @@ export class ShellToolInvocation extends BaseToolInvocation< } // Heuristic Sandbox Denial Detection - const lowerOutput = ( - (result.output || '') + - ' ' + - (result.error?.message || '') - ).toLowerCase(); - const isFileDenial = [ - 'operation not permitted', - 'vim:e303', - 'should be read/write', - 'sandbox_apply', - 'sandbox: ', - ].some((keyword) => lowerOutput.includes(keyword)); - - const isNetworkDenial = [ - 'error connecting to', - 'network is unreachable', - 'could not resolve host', - 'connection refused', - 'no address associated with hostname', - ].some((keyword) => lowerOutput.includes(keyword)); - - // Only trigger heuristic if the command actually failed (exit code != 0 or aborted) - const failed = + if ( !!result.error || !!result.signal || (result.exitCode !== undefined && result.exitCode !== 0) || - result.aborted; + result.aborted + ) { + const sandboxDenial = + this.context.config.sandboxManager.parseDenials(result); + if (sandboxDenial) { + const strippedCommand = stripShellWrapper(this.params.command); + const rootCommands = getCommandRoots(strippedCommand).filter( + (r) => r !== 'shopt', + ); + const rootCommandDisplay = + rootCommands.length > 0 ? rootCommands[0] : 'shell'; - if (failed && (isFileDenial || isNetworkDenial)) { - const strippedCommand = stripShellWrapper(this.params.command); - const rootCommands = getCommandRoots(strippedCommand).filter( - (r) => r !== 'shopt', - ); - const rootCommandDisplay = - rootCommands.length > 0 ? rootCommands[0] : 'shell'; - // Extract denied paths - const deniedPaths = new Set(); - const regex = - /(?:^|\s)['"]?(\/[\w.-/]+)['"]?:\s*[Oo]peration not permitted/gi; - let match; - while ((match = regex.exec(result.output || '')) !== null) { - deniedPaths.add(match[1]); - } - while ((match = regex.exec(result.error?.message || '')) !== null) { - deniedPaths.add(match[1]); - } + const readPaths = new Set( + this.params[PARAM_ADDITIONAL_PERMISSIONS]?.fileSystem?.read || [], + ); + const writePaths = new Set( + this.params[PARAM_ADDITIONAL_PERMISSIONS]?.fileSystem?.write || [], + ); - if (isFileDenial && deniedPaths.size === 0) { - // Fallback heuristic: look for any absolute path in the output - // Avoid matching simple commands like /bin/sh - const fallbackRegex = - /(?:^|[\s"'[\]])(\/[a-zA-Z0-9_.-]+(?:\/[a-zA-Z0-9_.-]+)+)(?:$|[\s"'[\]:])/gi; - let m; - while ((m = fallbackRegex.exec(result.output || '')) !== null) { - const p = m[1]; - if (p && !p.startsWith('/bin/') && !p.startsWith('/usr/bin/')) { - deniedPaths.add(p); - } - } - while ( - (m = fallbackRegex.exec(result.error?.message || '')) !== null - ) { - const p = m[1]; - if (p && !p.startsWith('/bin/') && !p.startsWith('/usr/bin/')) { - deniedPaths.add(p); - } - } - } - - const readPaths = new Set( - this.params[PARAM_ADDITIONAL_PERMISSIONS]?.fileSystem?.read || [], - ); - const writePaths = new Set( - this.params[PARAM_ADDITIONAL_PERMISSIONS]?.fileSystem?.write || [], - ); - - for (const p of deniedPaths) { - try { - // Find an existing parent directory to add instead of a non-existent file - let currentPath = p; - try { - if ( - fs.existsSync(currentPath) && - fs.statSync(currentPath).isFile() - ) { - currentPath = path.dirname(currentPath); - } - } catch (_e) { - /* ignore */ - } - while (currentPath.length > 1) { - if (fs.existsSync(currentPath)) { - writePaths.add(currentPath); - readPaths.add(currentPath); - break; - } - currentPath = path.dirname(currentPath); - } - } catch (_e) { - // ignore - } - } - - const additionalPermissions = { - network: - isNetworkDenial || - this.params[PARAM_ADDITIONAL_PERMISSIONS]?.network || - undefined, - fileSystem: - isFileDenial || writePaths.size > 0 - ? { - read: Array.from(readPaths), - write: Array.from(writePaths), + if (sandboxDenial.filePaths) { + for (const p of sandboxDenial.filePaths) { + try { + // Find an existing parent directory to add instead of a non-existent file + let currentPath = p; + try { + if ( + fs.existsSync(currentPath) && + fs.statSync(currentPath).isFile() + ) { + currentPath = path.dirname(currentPath); + } + } catch (_e) { + /* ignore */ } - : undefined, - }; + while (currentPath.length > 1) { + if (fs.existsSync(currentPath)) { + writePaths.add(currentPath); + readPaths.add(currentPath); + break; + } + currentPath = path.dirname(currentPath); + } + } catch (_e) { + // ignore + } + } + } - const originalReadSize = - this.params[PARAM_ADDITIONAL_PERMISSIONS]?.fileSystem?.read?.length || - 0; - const originalWriteSize = - this.params[PARAM_ADDITIONAL_PERMISSIONS]?.fileSystem?.write - ?.length || 0; - const originalNetwork = - !!this.params[PARAM_ADDITIONAL_PERMISSIONS]?.network; - - const newReadSize = additionalPermissions.fileSystem?.read?.length || 0; - const newWriteSize = - additionalPermissions.fileSystem?.write?.length || 0; - const newNetwork = !!additionalPermissions.network; - - const hasNewPermissions = - newReadSize > originalReadSize || - newWriteSize > originalWriteSize || - (!originalNetwork && newNetwork); - - if (hasNewPermissions) { - const confirmationDetails = { - type: 'sandbox_expansion', - title: 'Sandbox Expansion Request', - command: this.params.command, - rootCommand: rootCommandDisplay, - additionalPermissions, + const additionalPermissions = { + network: + sandboxDenial.network || + this.params[PARAM_ADDITIONAL_PERMISSIONS]?.network || + undefined, + fileSystem: + sandboxDenial.filePaths?.length || writePaths.size > 0 + ? { + read: Array.from(readPaths), + write: Array.from(writePaths), + } + : undefined, }; - return { - llmContent: 'Sandbox expansion required', - returnDisplay: returnDisplayMessage, - error: { - type: ToolErrorType.SANDBOX_EXPANSION_REQUIRED, - message: JSON.stringify(confirmationDetails), - }, - }; + const originalReadSize = + this.params[PARAM_ADDITIONAL_PERMISSIONS]?.fileSystem?.read + ?.length || 0; + const originalWriteSize = + this.params[PARAM_ADDITIONAL_PERMISSIONS]?.fileSystem?.write + ?.length || 0; + const originalNetwork = + !!this.params[PARAM_ADDITIONAL_PERMISSIONS]?.network; + + const newReadSize = + additionalPermissions.fileSystem?.read?.length || 0; + const newWriteSize = + additionalPermissions.fileSystem?.write?.length || 0; + const newNetwork = !!additionalPermissions.network; + + const hasNewPermissions = + newReadSize > originalReadSize || + newWriteSize > originalWriteSize || + (!originalNetwork && newNetwork); + + if (hasNewPermissions) { + const confirmationDetails = { + type: 'sandbox_expansion', + title: 'Sandbox Expansion Request', + command: this.params.command, + rootCommand: rootCommandDisplay, + additionalPermissions, + }; + + return { + llmContent: 'Sandbox expansion required', + returnDisplay: returnDisplayMessage, + error: { + type: ToolErrorType.SANDBOX_EXPANSION_REQUIRED, + message: JSON.stringify(confirmationDetails), + }, + }; + } + // If no new permissions were found by heuristic, do not intercept. + // Just return the normal execution error so the LLM can try providing explicit paths itself. } - // If no new permissions were found by heuristic, do not intercept. - // Just return the normal execution error so the LLM can try providing explicit paths itself. } const summarizeConfig = From b5ba88b00174dcaefe7e3e0ae92b8bb114a5e45e Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Thu, 26 Mar 2026 16:49:51 -0700 Subject: [PATCH 49/49] dep(update) Update Ink version to 6.5.0 (#23843) --- package-lock.json | 11 ++++++----- package.json | 4 ++-- packages/cli/package.json | 2 +- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index b4fdfdb439..f3bf8fa616 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "packages/*" ], "dependencies": { - "ink": "npm:@jrichman/ink@6.4.11", + "ink": "npm:@jrichman/ink@6.5.0", "latest-version": "^9.0.0", "node-fetch-native": "^1.6.7", "proper-lockfile": "^4.1.2", @@ -10089,9 +10089,9 @@ }, "node_modules/ink": { "name": "@jrichman/ink", - "version": "6.4.11", - "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.11.tgz", - "integrity": "sha512-93LQlzT7vvZ1XJcmOMwN4s+6W334QegendeHOMnEJBlhnpIzr8bws6/aOEHG8ZCuVD/vNeeea5m1msHIdAY6ig==", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.5.0.tgz", + "integrity": "sha512-S4g/ng7fPZmFwclO82iWkOce8vDLy/FIDgHIfkCWGOehqHe6dexHsmq3kNQD21okh198pA5SAQTCqNQJb/svRQ==", "license": "MIT", "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", @@ -10116,6 +10116,7 @@ "type-fest": "^4.27.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", + "yargs": "^17.7.2", "yoga-layout": "~3.2.1" }, "engines": { @@ -17550,7 +17551,7 @@ "fzf": "^0.5.2", "glob": "^12.0.0", "highlight.js": "^11.11.1", - "ink": "npm:@jrichman/ink@6.4.11", + "ink": "npm:@jrichman/ink@6.5.0", "ink-gradient": "^3.0.0", "ink-spinner": "^5.0.0", "latest-version": "^9.0.0", diff --git a/package.json b/package.json index d66132c066..73ebef63fd 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "pre-commit": "node scripts/pre-commit.js" }, "overrides": { - "ink": "npm:@jrichman/ink@6.4.11", + "ink": "npm:@jrichman/ink@6.5.0", "wrap-ansi": "9.0.2", "cliui": { "wrap-ansi": "7.0.0" @@ -136,7 +136,7 @@ "yargs": "^17.7.2" }, "dependencies": { - "ink": "npm:@jrichman/ink@6.4.11", + "ink": "npm:@jrichman/ink@6.5.0", "latest-version": "^9.0.0", "node-fetch-native": "^1.6.7", "proper-lockfile": "^4.1.2", diff --git a/packages/cli/package.json b/packages/cli/package.json index 40acd6cf88..072f2b8a72 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -49,7 +49,7 @@ "fzf": "^0.5.2", "glob": "^12.0.0", "highlight.js": "^11.11.1", - "ink": "npm:@jrichman/ink@6.4.11", + "ink": "npm:@jrichman/ink@6.5.0", "ink-gradient": "^3.0.0", "ink-spinner": "^5.0.0", "latest-version": "^9.0.0",