Compare commits

..

1 Commits

Author SHA1 Message Date
gemini-cli-robot 994edeb931 chore(release): v0.22.0 2025-12-22 16:26:42 +00:00
49 changed files with 206 additions and 1250 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 387 KiB

-5
View File
@@ -731,11 +731,6 @@ their corresponding top-level category object in your `settings.json` file.
- **Default:** `false`
- **Requires restart:** Yes
- **`security.enablePermanentToolApproval`** (boolean):
- **Description:** Enable the "Allow for all future sessions" option in tool
confirmation dialogs.
- **Default:** `false`
- **`security.blockGitExtensions`** (boolean):
- **Description:** Blocks installing and loading extensions from Git.
- **Default:** `false`
+1 -1
View File
@@ -24,7 +24,7 @@ Rename the photos in my "photos" directory based on their contents.
Result: Gemini will ask for permission to rename your files.
Select **Allow once** and your files will be renamed:
Select **Yes, allow once.** and your files will be renamed:
```bash
photos/yellow_flowers.png
+2 -2
View File
@@ -128,8 +128,8 @@ editor.
You can also **modify the suggested changes** directly in the diff view before
accepting them.
If you select Allow for this session in the CLI, changes will no longer show
up in the IDE as they will be auto-accepted.
If you select Yes, allow always in the CLI, changes will no longer show up in
the IDE as they will be auto-accepted.
## Using with sandboxing
+7 -7
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.22.5",
"version": "0.22.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.22.5",
"version": "0.22.0",
"workspaces": [
"packages/*"
],
@@ -17671,7 +17671,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.22.5",
"version": "0.22.0",
"dependencies": {
"@a2a-js/sdk": "^0.3.2",
"@google-cloud/storage": "^7.16.0",
@@ -17981,7 +17981,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.22.5",
"version": "0.22.0",
"license": "Apache-2.0",
"dependencies": {
"@google/gemini-cli-core": "file:../core",
@@ -18083,7 +18083,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.22.5",
"version": "0.22.0",
"license": "Apache-2.0",
"dependencies": {
"@google-cloud/logging": "^11.2.1",
@@ -18228,7 +18228,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.22.5",
"version": "0.22.0",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^5.3.3"
@@ -18239,7 +18239,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.22.5",
"version": "0.22.0",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.22.5",
"version": "0.22.0",
"engines": {
"node": ">=20.0.0"
},
@@ -14,7 +14,7 @@
"url": "git+https://github.com/google-gemini/gemini-cli.git"
},
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.22.5"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.22.0"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.22.5",
"version": "0.22.0",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.22.5",
"version": "0.22.0",
"description": "Gemini CLI",
"license": "Apache-2.0",
"repository": {
@@ -26,7 +26,7 @@
"dist"
],
"config": {
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.22.5"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.22.0"
},
"dependencies": {
"@google/gemini-cli-core": "file:../core",
-10
View File
@@ -1164,16 +1164,6 @@ const SETTINGS_SCHEMA = {
description: 'Disable YOLO mode, even if enabled by a flag.',
showInDialog: true,
},
enablePermanentToolApproval: {
type: 'boolean',
label: 'Allow Permanent Tool Approval',
category: 'Security',
requiresRestart: false,
default: false,
description:
'Enable the "Allow for all future sessions" option in tool confirmation dialogs.',
showInDialog: true,
},
blockGitExtensions: {
type: 'boolean',
label: 'Blocks extensions from Git',
@@ -32,7 +32,6 @@ describe('<AppHeader />', () => {
it('should render the banner with default text', () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
@@ -53,7 +52,6 @@ describe('<AppHeader />', () => {
it('should render the banner with warning text', () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: 'There are capacity issues',
@@ -74,7 +72,6 @@ describe('<AppHeader />', () => {
it('should not render the banner when no flags are set', () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: '',
warningText: '',
@@ -94,7 +91,6 @@ describe('<AppHeader />', () => {
it('should render the banner when previewFeatures is disabled', () => {
const mockConfig = makeFakeConfig({ previewFeatures: false });
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
@@ -115,7 +111,6 @@ describe('<AppHeader />', () => {
it('should not render the banner when previewFeatures is enabled', () => {
const mockConfig = makeFakeConfig({ previewFeatures: true });
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
@@ -136,7 +131,6 @@ describe('<AppHeader />', () => {
persistentStateMock.get.mockReturnValue(5);
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
@@ -157,7 +151,6 @@ describe('<AppHeader />', () => {
persistentStateMock.get.mockReturnValue({});
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
@@ -184,7 +177,6 @@ describe('<AppHeader />', () => {
it('should render banner text with unescaped newlines', () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'First line\\nSecond line',
warningText: '',
@@ -15,9 +15,6 @@ import { Text } from 'ink';
import type React from 'react';
vi.mock('../hooks/useTerminalSize.js');
vi.mock('../hooks/useSnowfall.js', () => ({
useSnowfall: vi.fn((art) => art),
}));
vi.mock('../utils/terminalSetup.js', () => ({
getTerminalProgram: vi.fn(),
}));
@@ -162,6 +159,7 @@ describe('<Header />', () => {
render(<Header version="1.0.0" nightly={false} />);
expect(Gradient.default).not.toHaveBeenCalled();
const textCalls = (Text as Mock).mock.calls;
console.log(JSON.stringify(textCalls, null, 2));
expect(textCalls.length).toBe(1);
expect(textCalls[0][0]).toHaveProperty('color', singleColor);
});
+1 -3
View File
@@ -18,7 +18,6 @@ import {
import { getAsciiArtWidth } from '../utils/textUtils.js';
import { useTerminalSize } from '../hooks/useTerminalSize.js';
import { getTerminalProgram } from '../utils/terminalSetup.js';
import { useSnowfall } from '../hooks/useSnowfall.js';
interface HeaderProps {
customAsciiArt?: string; // For user-defined ASCII art
@@ -48,7 +47,6 @@ export const Header: React.FC<HeaderProps> = ({
}
const artWidth = getAsciiArtWidth(displayTitle);
const title = useSnowfall(displayTitle);
return (
<Box
@@ -57,7 +55,7 @@ export const Header: React.FC<HeaderProps> = ({
flexShrink={0}
flexDirection="column"
>
<ThemedGradient>{title}</ThemedGradient>
<ThemedGradient>{displayTitle}</ThemedGradient>
{nightly && (
<Box width="100%" flexDirection="row" justifyContent="flex-end">
<ThemedGradient>v{version}</ThemedGradient>
@@ -24,23 +24,23 @@ describe('ShellConfirmationDialog', () => {
expect(lastFrame()).toMatchSnapshot();
});
it('calls onConfirm with ProceedOnce when "Allow once" is selected', () => {
it('calls onConfirm with ProceedOnce when "Yes, allow once" is selected', () => {
const { lastFrame } = renderWithProviders(
<ShellConfirmationDialog request={request} />,
);
const select = lastFrame()!.toString();
// Simulate selecting the first option
// This is a simplified way to test the selection
expect(select).toContain('Allow once');
expect(select).toContain('Yes, allow once');
});
it('calls onConfirm with ProceedAlways when "Allow for this session" is selected', () => {
it('calls onConfirm with ProceedAlways when "Yes, allow always for this session" is selected', () => {
const { lastFrame } = renderWithProviders(
<ShellConfirmationDialog request={request} />,
);
const select = lastFrame()!.toString();
// Simulate selecting the second option
expect(select).toContain('Allow for this session');
expect(select).toContain('Yes, allow always for this session');
});
it('calls onConfirm with Cancel when "No (esc)" is selected', () => {
@@ -51,14 +51,14 @@ export const ShellConfirmationDialog: React.FC<
const options: Array<RadioSelectItem<ToolConfirmationOutcome>> = [
{
label: 'Allow once',
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
key: 'Allow once',
key: 'Yes, allow once',
},
{
label: 'Allow for this session',
label: 'Yes, allow always for this session',
value: ToolConfirmationOutcome.ProceedAlways,
key: 'Allow for this session',
key: 'Yes, allow always for this session',
},
{
label: 'No (esc)',
@@ -13,8 +13,8 @@ exports[`ShellConfirmationDialog > renders correctly 1`] = `
│ │
│ Do you want to proceed? │
│ │
│ ● 1. Allow once
│ 2. Allow for this session
│ ● 1. Yes, allow once │
│ 2. Yes, allow always for this session │
│ 3. No (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
@@ -10,10 +10,7 @@ import type {
ToolCallConfirmationDetails,
Config,
} from '@google/gemini-cli-core';
import {
renderWithProviders,
createMockSettings,
} from '../../../test-utils/render.js';
import { renderWithProviders } from '../../../test-utils/render.js';
describe('ToolConfirmationMessage', () => {
const mockConfig = {
@@ -107,17 +104,17 @@ describe('ToolConfirmationMessage', () => {
{
description: 'for edit confirmations',
details: editConfirmationDetails,
alwaysAllowText: 'Allow for this session',
alwaysAllowText: 'Yes, allow always',
},
{
description: 'for exec confirmations',
details: execConfirmationDetails,
alwaysAllowText: 'Allow for this session',
alwaysAllowText: 'Yes, allow always',
},
{
description: 'for info confirmations',
details: infoConfirmationDetails,
alwaysAllowText: 'Allow for this session',
alwaysAllowText: 'Yes, allow always',
},
{
description: 'for mcp confirmations',
@@ -162,63 +159,4 @@ describe('ToolConfirmationMessage', () => {
});
});
});
describe('enablePermanentToolApproval setting', () => {
const editConfirmationDetails: ToolCallConfirmationDetails = {
type: 'edit',
title: 'Confirm Edit',
fileName: 'test.txt',
filePath: '/test.txt',
fileDiff: '...diff...',
originalContent: 'a',
newContent: 'b',
onConfirm: vi.fn(),
};
it('should NOT show "Allow for all future sessions" when setting is false (default)', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={editConfirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
{
settings: createMockSettings({
security: { enablePermanentToolApproval: false },
}),
},
);
expect(lastFrame()).not.toContain('Allow for all future sessions');
});
it('should show "Allow for all future sessions" when setting is true', () => {
const mockConfig = {
isTrustedFolder: () => true,
getIdeMode: () => false,
} as unknown as Config;
const { lastFrame } = renderWithProviders(
<ToolConfirmationMessage
confirmationDetails={editConfirmationDetails}
config={mockConfig}
availableTerminalHeight={30}
terminalWidth={80}
/>,
{
settings: createMockSettings({
security: { enablePermanentToolApproval: true },
}),
},
);
expect(lastFrame()).toContain('Allow for all future sessions');
});
});
});
@@ -20,7 +20,6 @@ import { MaxSizedBox } from '../shared/MaxSizedBox.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { theme } from '../../semantic-colors.js';
import { useAlternateBuffer } from '../../hooks/useAlternateBuffer.js';
import { useSettings } from '../../contexts/SettingsContext.js';
export interface ToolConfirmationMessageProps {
confirmationDetails: ToolCallConfirmationDetails;
@@ -42,9 +41,6 @@ export const ToolConfirmationMessage: React.FC<
const { onConfirm } = confirmationDetails;
const isAlternateBuffer = useAlternateBuffer();
const settings = useSettings();
const allowPermanentApproval =
settings.merged.security?.enablePermanentToolApproval ?? false;
const [ideClient, setIdeClient] = useState<IdeClient | null>(null);
const [isDiffingEnabled, setIsDiffingEnabled] = useState(false);
@@ -106,23 +102,21 @@ export const ToolConfirmationMessage: React.FC<
if (!confirmationDetails.isModifying) {
question = `Apply this change?`;
options.push({
label: 'Allow once',
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
key: 'Allow once',
key: 'Yes, allow once',
});
if (isTrustedFolder) {
options.push({
label: 'Allow for this session',
label: 'Yes, allow always',
value: ToolConfirmationOutcome.ProceedAlways,
key: 'Allow for this session',
key: 'Yes, allow always',
});
options.push({
label: 'Yes, allow always and save to policy',
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
key: 'Yes, allow always and save to policy',
});
if (allowPermanentApproval) {
options.push({
label: 'Allow for all future sessions',
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
key: 'Allow for all future sessions',
});
}
}
if (!config.getIdeMode() || !isDiffingEnabled) {
options.push({
@@ -143,23 +137,21 @@ export const ToolConfirmationMessage: React.FC<
question = `Allow execution of: '${executionProps.rootCommand}'?`;
options.push({
label: 'Allow once',
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
key: 'Allow once',
key: 'Yes, allow once',
});
if (isTrustedFolder) {
options.push({
label: `Allow for this session`,
label: `Yes, allow always ...`,
value: ToolConfirmationOutcome.ProceedAlways,
key: `Allow for this session`,
key: `Yes, allow always ...`,
});
options.push({
label: `Yes, allow always and save to policy`,
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
key: `Yes, allow always and save to policy`,
});
if (allowPermanentApproval) {
options.push({
label: `Allow for all future sessions`,
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
key: `Allow for all future sessions`,
});
}
}
options.push({
label: 'No, suggest changes (esc)',
@@ -169,23 +161,21 @@ export const ToolConfirmationMessage: React.FC<
} else if (confirmationDetails.type === 'info') {
question = `Do you want to proceed?`;
options.push({
label: 'Allow once',
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
key: 'Allow once',
key: 'Yes, allow once',
});
if (isTrustedFolder) {
options.push({
label: 'Allow for this session',
label: 'Yes, allow always',
value: ToolConfirmationOutcome.ProceedAlways,
key: 'Allow for this session',
key: 'Yes, allow always',
});
options.push({
label: 'Yes, allow always and save to policy',
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
key: 'Yes, allow always and save to policy',
});
if (allowPermanentApproval) {
options.push({
label: 'Allow for all future sessions',
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
key: 'Allow for all future sessions',
});
}
}
options.push({
label: 'No, suggest changes (esc)',
@@ -197,28 +187,26 @@ export const ToolConfirmationMessage: React.FC<
const mcpProps = confirmationDetails;
question = `Allow execution of MCP tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"?`;
options.push({
label: 'Allow once',
label: 'Yes, allow once',
value: ToolConfirmationOutcome.ProceedOnce,
key: 'Allow once',
key: 'Yes, allow once',
});
if (isTrustedFolder) {
options.push({
label: 'Allow tool for this session',
value: ToolConfirmationOutcome.ProceedAlwaysTool,
key: 'Allow tool for this session',
label: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
value: ToolConfirmationOutcome.ProceedAlwaysTool, // Cast until types are updated
key: `Yes, always allow tool "${mcpProps.toolName}" from server "${mcpProps.serverName}"`,
});
options.push({
label: 'Allow all server tools for this session',
label: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
value: ToolConfirmationOutcome.ProceedAlwaysServer,
key: 'Allow all server tools for this session',
key: `Yes, always allow all tools from server "${mcpProps.serverName}"`,
});
options.push({
label: `Yes, allow always tool "${mcpProps.toolName}" and save to policy`,
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
key: `Yes, allow always tool "${mcpProps.toolName}" and save to policy`,
});
if (allowPermanentApproval) {
options.push({
label: 'Allow tool for all future sessions',
value: ToolConfirmationOutcome.ProceedAlwaysAndSave,
key: 'Allow tool for all future sessions',
});
}
}
options.push({
label: 'No, suggest changes (esc)',
@@ -339,7 +327,6 @@ export const ToolConfirmationMessage: React.FC<
availableTerminalHeight,
terminalWidth,
isAlternateBuffer,
allowPermanentApproval,
]);
if (confirmationDetails.type === 'edit') {
@@ -4,10 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import {
renderWithProviders,
createMockSettings,
} from '../../../test-utils/render.js';
import { renderWithProviders } from '../../../test-utils/render.js';
import { describe, it, expect, vi } from 'vitest';
import { ToolGroupMessage } from './ToolGroupMessage.js';
import type { IndividualToolCallDisplay } from '../../types.js';
@@ -379,57 +376,5 @@ describe('<ToolGroupMessage />', () => {
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders confirmation with permanent approval enabled', () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
name: 'confirm-tool',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Confirm Tool',
prompt: 'Do you want to proceed?',
onConfirm: vi.fn(),
},
}),
];
const settings = createMockSettings({
security: { enablePermanentToolApproval: true },
});
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{ settings },
);
expect(lastFrame()).toContain('Allow for all future sessions');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
it('renders confirmation with permanent approval disabled', () => {
const toolCalls = [
createToolCall({
callId: 'tool-1',
name: 'confirm-tool',
status: ToolCallStatus.Confirming,
confirmationDetails: {
type: 'info',
title: 'Confirm Tool',
prompt: 'Do you want to proceed?',
onConfirm: vi.fn(),
},
}),
];
const settings = createMockSettings({
security: { enablePermanentToolApproval: false },
});
const { lastFrame, unmount } = renderWithProviders(
<ToolGroupMessage {...baseProps} toolCalls={toolCalls} />,
{ settings },
);
expect(lastFrame()).not.toContain('Allow for all future sessions');
expect(lastFrame()).toMatchSnapshot();
unmount();
});
});
});
@@ -8,9 +8,10 @@ URLs to fetch:
Do you want to proceed?
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
● 1. Yes, allow once
2. Yes, allow always
3. Yes, allow always and save to policy
4. No, suggest changes (esc)
"
`;
@@ -19,9 +20,10 @@ exports[`ToolConfirmationMessage > should not display urls if prompt and url are
Do you want to proceed?
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
● 1. Yes, allow once
2. Yes, allow always
3. Yes, allow always and save to policy
4. No, suggest changes (esc)
"
`;
@@ -34,7 +36,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
Apply this change?
● 1. Allow once
● 1. Yes, allow once
2. Modify with external editor
3. No, suggest changes (esc)
"
@@ -49,10 +51,11 @@ exports[`ToolConfirmationMessage > with folder trust > 'for edit confirmations'
Apply this change?
● 1. Allow once
2. Allow for this session
3. Modify with external editor
4. No, suggest changes (esc)
● 1. Yes, allow once
2. Yes, allow always
3. Yes, allow always and save to policy
4. Modify with external editor
5. No, suggest changes (esc)
"
`;
@@ -61,7 +64,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
Allow execution of: 'echo'?
● 1. Allow once
● 1. Yes, allow once
2. No, suggest changes (esc)
"
`;
@@ -71,9 +74,10 @@ exports[`ToolConfirmationMessage > with folder trust > 'for exec confirmations'
Allow execution of: 'echo'?
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
● 1. Yes, allow once
2. Yes, allow always ...
3. Yes, allow always and save to policy
4. No, suggest changes (esc)
"
`;
@@ -82,7 +86,7 @@ exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations'
Do you want to proceed?
● 1. Allow once
● 1. Yes, allow once
2. No, suggest changes (esc)
"
`;
@@ -92,9 +96,10 @@ exports[`ToolConfirmationMessage > with folder trust > 'for info confirmations'
Do you want to proceed?
● 1. Allow once
2. Allow for this session
3. No, suggest changes (esc)
● 1. Yes, allow once
2. Yes, allow always
3. Yes, allow always and save to policy
4. No, suggest changes (esc)
"
`;
@@ -104,7 +109,7 @@ Tool: test-tool
Allow execution of MCP tool "test-tool" from server "test-server"?
● 1. Allow once
● 1. Yes, allow once
2. No, suggest changes (esc)
"
`;
@@ -115,9 +120,10 @@ Tool: test-tool
Allow execution of MCP tool "test-tool" from server "test-server"?
● 1. Allow once
2. Allow tool for this session
3. Allow all server tools for this session
4. No, suggest changes (esc)
● 1. Yes, allow once
2. Yes, always allow tool "test-tool" from server "test-server"
3. Yes, always allow all tools from server "test-server"
4. Yes, allow always tool "test-tool" and save to policy
5. No, suggest changes (esc)
"
`;
@@ -28,39 +28,6 @@ exports[`<ToolGroupMessage /> > Border Color Logic > uses yellow border when too
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Confirmation Handling > renders confirmation with permanent approval disabled 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ? confirm-tool A tool for testing ← │
│ │
│ Test result │
│ Do you want to proceed? │
│ │
│ Do you want to proceed? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
│ 3. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Confirmation Handling > renders confirmation with permanent approval enabled 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ? confirm-tool A tool for testing ← │
│ │
│ Test result │
│ Do you want to proceed? │
│ │
│ Do you want to proceed? │
│ │
│ ● 1. Allow once │
│ 2. Allow for this session │
│ 3. Allow for all future sessions │
│ 4. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
exports[`<ToolGroupMessage /> > Confirmation Handling > shows confirmation dialog for first confirming tool only 1`] = `
"╭──────────────────────────────────────────────────────────────────────────────╮
│ ? first-confirm A tool for testing ← │
@@ -70,9 +37,10 @@ exports[`<ToolGroupMessage /> > Confirmation Handling > shows confirmation dialo
│ │
│ Do you want to proceed? │
│ │
│ ● 1. Allow once
│ 2. Allow for this session
│ 3. No, suggest changes (esc)
│ ● 1. Yes, allow once │
│ 2. Yes, allow always
│ 3. Yes, allow always and save to policy
│ 4. No, suggest changes (esc) │
│ │
│ │
│ ? second-confirm A tool for testing │
@@ -153,9 +121,10 @@ exports[`<ToolGroupMessage /> > Golden Snapshots > renders tool call awaiting co
│ │
│ Do you want to proceed? │
│ │
│ ● 1. Allow once
│ 2. Allow for this session
│ 3. No, suggest changes (esc)
│ ● 1. Yes, allow once │
│ 2. Yes, allow always
│ 3. Yes, allow always and save to policy
│ 4. No, suggest changes (esc) │
│ │
╰──────────────────────────────────────────────────────────────────────────────╯"
`;
@@ -1,108 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useSnowfall } from './useSnowfall.js';
import { themeManager } from '../themes/theme-manager.js';
import { renderHookWithProviders } from '../../test-utils/render.js';
import { act } from 'react';
import { debugState } from '../debug.js';
import type { Theme } from '../themes/theme.js';
import type { UIState } from '../contexts/UIStateContext.js';
vi.mock('../themes/theme-manager.js', () => ({
themeManager: {
getActiveTheme: vi.fn(),
},
}));
vi.mock('../themes/holiday.js', () => ({
Holiday: { name: 'Holiday' },
}));
vi.mock('./useTerminalSize.js', () => ({
useTerminalSize: vi.fn(() => ({ columns: 120, rows: 20 })),
}));
describe('useSnowfall', () => {
const mockArt = 'LOGO';
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
vi.mocked(themeManager.getActiveTheme).mockReturnValue({
name: 'Holiday',
} as Theme);
vi.setSystemTime(new Date('2025-12-25'));
debugState.debugNumAnimatedComponents = 0;
});
afterEach(() => {
vi.useRealTimers();
});
it('initially enables animation during holiday season with Holiday theme', () => {
const { result } = renderHookWithProviders(() => useSnowfall(mockArt), {
uiState: { history: [], historyRemountKey: 0 } as Partial<UIState>,
});
// Should contain holiday trees
expect(result.current).toContain('|_|');
// Should have started animation
expect(debugState.debugNumAnimatedComponents).toBeGreaterThan(0);
});
it('stops animation after 15 seconds', () => {
const { result } = renderHookWithProviders(() => useSnowfall(mockArt), {
uiState: { history: [], historyRemountKey: 0 } as Partial<UIState>,
});
expect(debugState.debugNumAnimatedComponents).toBeGreaterThan(0);
act(() => {
vi.advanceTimersByTime(15001);
});
// Animation should be stopped
expect(debugState.debugNumAnimatedComponents).toBe(0);
// Should no longer contain trees
expect(result.current).toBe(mockArt);
});
it('does not enable animation if not holiday season', () => {
vi.setSystemTime(new Date('2025-06-15'));
const { result } = renderHookWithProviders(() => useSnowfall(mockArt), {
uiState: { history: [], historyRemountKey: 0 } as Partial<UIState>,
});
expect(result.current).toBe(mockArt);
expect(debugState.debugNumAnimatedComponents).toBe(0);
});
it('does not enable animation if theme is not Holiday', () => {
vi.mocked(themeManager.getActiveTheme).mockReturnValue({
name: 'Default',
} as Theme);
const { result } = renderHookWithProviders(() => useSnowfall(mockArt), {
uiState: { history: [], historyRemountKey: 0 } as Partial<UIState>,
});
expect(result.current).toBe(mockArt);
expect(debugState.debugNumAnimatedComponents).toBe(0);
});
it('does not enable animation if chat has started', () => {
const { result } = renderHookWithProviders(() => useSnowfall(mockArt), {
uiState: {
history: [{ type: 'user', text: 'hello' }],
historyRemountKey: 0,
} as Partial<UIState>,
});
expect(result.current).toBe(mockArt);
expect(debugState.debugNumAnimatedComponents).toBe(0);
});
});
-162
View File
@@ -1,162 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo } from 'react';
import { getAsciiArtWidth } from '../utils/textUtils.js';
import { debugState } from '../debug.js';
import { themeManager } from '../themes/theme-manager.js';
import { Holiday } from '../themes/holiday.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useTerminalSize } from './useTerminalSize.js';
import { shortAsciiLogo } from '../components/AsciiArt.js';
interface Snowflake {
x: number;
y: number;
char: string;
}
const SNOW_CHARS = ['*', '.', '·', '+'];
const FRAME_RATE = 150; // ms
const addHolidayTrees = (art: string): string => {
const holidayTree = `
*
***
*****
*******
*********
|_|`;
const treeLines = holidayTree.split('\n').filter((l) => l.length > 0);
const treeWidth = getAsciiArtWidth(holidayTree);
const logoWidth = getAsciiArtWidth(art);
// Create three trees side by side
const treeSpacing = ' ';
const tripleTreeLines = treeLines.map((line) => {
const paddedLine = line.padEnd(treeWidth, ' ');
return `${paddedLine}${treeSpacing}${paddedLine}${treeSpacing}${paddedLine}`;
});
const tripleTreeWidth = treeWidth * 3 + treeSpacing.length * 2;
const paddingCount = Math.max(
0,
Math.floor((logoWidth - tripleTreeWidth) / 2),
);
const treePadding = ' '.repeat(paddingCount);
const centeredTripleTrees = tripleTreeLines
.map((line) => treePadding + line)
.join('\n');
// Add vertical padding and the trees below the logo
return `\n\n${art}\n${centeredTripleTrees}\n\n`;
};
export const useSnowfall = (displayTitle: string): string => {
const isHolidaySeason =
new Date().getMonth() === 11 || new Date().getMonth() === 0;
const currentTheme = themeManager.getActiveTheme();
const { columns: terminalWidth } = useTerminalSize();
const { history, historyRemountKey } = useUIState();
const hasStartedChat = history.some(
(item) => item.type === 'user' && item.text !== '/theme',
);
const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo);
const [showSnow, setShowSnow] = useState(true);
useEffect(() => {
setShowSnow(true);
const timer = setTimeout(() => {
setShowSnow(false);
}, 15000);
return () => clearTimeout(timer);
}, [historyRemountKey]);
const showAnimation =
isHolidaySeason &&
currentTheme.name === Holiday.name &&
terminalWidth >= widthOfShortLogo &&
!hasStartedChat &&
showSnow;
const displayArt = useMemo(() => {
if (showAnimation) {
return addHolidayTrees(displayTitle);
}
return displayTitle;
}, [displayTitle, showAnimation]);
const [snowflakes, setSnowflakes] = useState<Snowflake[]>([]);
// We don't need 'frame' state if we just use functional updates for snowflakes,
// but we need a trigger. A simple interval is fine.
const lines = displayArt.split('\n');
const height = lines.length;
const width = getAsciiArtWidth(displayArt);
useEffect(() => {
if (!showAnimation) {
setSnowflakes([]);
return;
}
debugState.debugNumAnimatedComponents++;
const timer = setInterval(() => {
setSnowflakes((prev) => {
// Move existing flakes
const moved = prev
.map((flake) => ({ ...flake, y: flake.y + 1 }))
.filter((flake) => flake.y < height);
// Spawn new flakes
// Adjust spawn rate based on width to keep density consistent
const spawnChance = 0.3;
const newFlakes: Snowflake[] = [];
if (Math.random() < spawnChance) {
// Spawn 1 to 2 flakes
const count = Math.floor(Math.random() * 2) + 1;
for (let i = 0; i < count; i++) {
newFlakes.push({
x: Math.floor(Math.random() * width),
y: 0,
char: SNOW_CHARS[Math.floor(Math.random() * SNOW_CHARS.length)],
});
}
}
return [...moved, ...newFlakes];
});
}, FRAME_RATE);
return () => {
debugState.debugNumAnimatedComponents--;
clearInterval(timer);
};
}, [height, width, showAnimation]);
if (!showAnimation) return displayTitle;
// Render current frame
if (snowflakes.length === 0) return displayArt;
const grid = lines.map((line) => line.padEnd(width, ' ').split(''));
snowflakes.forEach((flake) => {
if (flake.y >= 0 && flake.y < height && flake.x >= 0 && flake.x < width) {
// Overwrite with snow character
// We check if the row exists just in case
if (grid[flake.y]) {
grid[flake.y][flake.x] = flake.char;
}
}
});
return grid.map((row) => row.join('')).join('\n');
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-core",
"version": "0.22.5",
"version": "0.22.0",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
+5 -7
View File
@@ -361,6 +361,7 @@ export class Config {
private userMemory: string;
private geminiMdFileCount: number;
private geminiMdFilePaths: string[];
private approvalMode: ApprovalMode;
private readonly showMemoryUsage: boolean;
private readonly accessibility: AccessibilitySettings;
private readonly telemetrySettings: TelemetrySettings;
@@ -480,6 +481,7 @@ export class Config {
this.userMemory = params.userMemory ?? '';
this.geminiMdFileCount = params.geminiMdFileCount ?? 0;
this.geminiMdFilePaths = params.geminiMdFilePaths ?? [];
this.approvalMode = params.approvalMode ?? ApprovalMode.DEFAULT;
this.showMemoryUsage = params.showMemoryUsage ?? false;
this.accessibility = params.accessibility ?? {};
this.telemetrySettings = {
@@ -594,11 +596,7 @@ export class Config {
this.enablePromptCompletion = params.enablePromptCompletion ?? false;
this.fileExclusions = new FileExclusions(this);
this.eventEmitter = params.eventEmitter;
this.policyEngine = new PolicyEngine({
...params.policyEngineConfig,
approvalMode:
params.approvalMode ?? params.policyEngineConfig?.approvalMode,
});
this.policyEngine = new PolicyEngine(params.policyEngineConfig);
this.messageBus = new MessageBus(this.policyEngine, this.debugMode);
this.outputSettings = {
format: params.output?.format ?? OutputFormat.TEXT,
@@ -1129,7 +1127,7 @@ export class Config {
}
getApprovalMode(): ApprovalMode {
return this.policyEngine.getApprovalMode();
return this.approvalMode;
}
setApprovalMode(mode: ApprovalMode): void {
@@ -1138,7 +1136,7 @@ export class Config {
'Cannot enable privileged approval modes in an untrusted folder.',
);
}
this.policyEngine.setApprovalMode(mode);
this.approvalMode = mode;
}
isYoloModeDisabled(): boolean {
+1 -1
View File
@@ -41,7 +41,7 @@ export interface UpdatePolicy {
toolName: string;
persist?: boolean;
argsPattern?: string;
commandPrefix?: string | string[];
commandPrefix?: string;
mcpName?: string;
}
+19 -39
View File
@@ -124,7 +124,7 @@ export async function createPolicyEngineConfig(
rules: tomlRules,
checkers: tomlCheckers,
errors,
} = await loadPoliciesFromToml(policyDirs, (dir) =>
} = await loadPoliciesFromToml(approvalMode, policyDirs, (dir) =>
getPolicyTier(dir, defaultPoliciesDir),
);
@@ -236,7 +236,6 @@ export async function createPolicyEngineConfig(
rules,
checkers,
defaultDecision: PolicyDecision.ASK_USER,
approvalMode,
};
}
@@ -245,7 +244,7 @@ interface TomlRule {
mcpName?: string;
decision?: string;
priority?: number;
commandPrefix?: string | string[];
commandPrefix?: string;
argsPattern?: string;
// Index signature to satisfy Record type if needed for toml.stringify
[key: string]: unknown;
@@ -259,46 +258,27 @@ export function createPolicyUpdater(
MessageBusType.UPDATE_POLICY,
async (message: UpdatePolicy) => {
const toolName = message.toolName;
let argsPattern = message.argsPattern
? new RegExp(message.argsPattern)
: undefined;
if (message.commandPrefix) {
// Convert commandPrefix(es) to argsPatterns for in-memory rules
const prefixes = Array.isArray(message.commandPrefix)
? message.commandPrefix
: [message.commandPrefix];
for (const prefix of prefixes) {
const escapedPrefix = escapeRegex(prefix);
// Use robust regex to match whole words (e.g. "git" but not "github")
const argsPattern = new RegExp(
`"command":"${escapedPrefix}(?:[\\s"]|$)`,
);
policyEngine.addRule({
toolName,
decision: PolicyDecision.ALLOW,
// User tier (2) + high priority (950/1000) = 2.95
// This ensures user "always allow" selections are high priority
// but still lose to admin policies (3.xxx) and settings excludes (200)
priority: 2.95,
argsPattern,
});
}
} else {
const argsPattern = message.argsPattern
? new RegExp(message.argsPattern)
: undefined;
policyEngine.addRule({
toolName,
decision: PolicyDecision.ALLOW,
// User tier (2) + high priority (950/1000) = 2.95
// This ensures user "always allow" selections are high priority
// but still lose to admin policies (3.xxx) and settings excludes (200)
priority: 2.95,
argsPattern,
});
// Convert commandPrefix to argsPattern for in-memory rule
// This mimics what toml-loader does
const escapedPrefix = escapeRegex(message.commandPrefix);
argsPattern = new RegExp(`"command":"${escapedPrefix}`);
}
policyEngine.addRule({
toolName,
decision: PolicyDecision.ALLOW,
// User tier (2) + high priority (950/1000) = 2.95
// This ensures user "always allow" selections are high priority
// but still lose to admin policies (3.xxx) and settings excludes (200)
priority: 2.95,
argsPattern,
});
if (message.persist) {
try {
const userPoliciesDir = Storage.getUserPoliciesDir();
+2 -9
View File
@@ -20,7 +20,6 @@ import { PolicyEngine } from './policy-engine.js';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import { Storage } from '../config/storage.js';
import { ApprovalMode } from './types.js';
vi.mock('node:fs/promises');
vi.mock('../config/storage.js');
@@ -30,11 +29,7 @@ describe('createPolicyUpdater', () => {
let messageBus: MessageBus;
beforeEach(() => {
policyEngine = new PolicyEngine({
rules: [],
checkers: [],
approvalMode: ApprovalMode.DEFAULT,
});
policyEngine = new PolicyEngine({ rules: [], checkers: [] });
messageBus = new MessageBus(policyEngine);
vi.clearAllMocks();
});
@@ -126,9 +121,7 @@ describe('createPolicyUpdater', () => {
const addedRule = rules.find((r) => r.toolName === toolName);
expect(addedRule).toBeDefined();
expect(addedRule?.priority).toBe(2.95);
expect(addedRule?.argsPattern).toEqual(
new RegExp(`"command":"git status(?:[\\s"]|$)`),
);
expect(addedRule?.argsPattern).toEqual(new RegExp(`"command":"git status`));
// Verify file written
expect(fs.writeFile).toHaveBeenCalledWith(
+1 -40
View File
@@ -12,7 +12,6 @@ import {
type PolicyEngineConfig,
type SafetyCheckerRule,
InProcessCheckerType,
ApprovalMode,
} from './types.js';
import type { FunctionCall } from '@google/genai';
import { SafetyCheckDecision } from '../safety/protocol.js';
@@ -26,10 +25,7 @@ describe('PolicyEngine', () => {
mockCheckerRunner = {
runChecker: vi.fn(),
} as unknown as CheckerRunner;
engine = new PolicyEngine(
{ approvalMode: ApprovalMode.DEFAULT },
mockCheckerRunner,
);
engine = new PolicyEngine({}, mockCheckerRunner);
});
describe('constructor', () => {
@@ -167,41 +163,6 @@ describe('PolicyEngine', () => {
(await engine.check({ name: 'unknown-tool' }, undefined)).decision,
).toBe(PolicyDecision.DENY);
});
it('should dynamically switch between modes and respect rule modes', async () => {
const rules: PolicyRule[] = [
{
toolName: 'edit',
decision: PolicyDecision.ASK_USER,
priority: 10,
},
{
toolName: 'edit',
decision: PolicyDecision.ALLOW,
priority: 20,
modes: [ApprovalMode.AUTO_EDIT],
},
];
engine = new PolicyEngine({ rules });
// Default mode: priority 20 rule doesn't match, falls back to priority 10
expect((await engine.check({ name: 'edit' }, undefined)).decision).toBe(
PolicyDecision.ASK_USER,
);
// Switch to autoEdit mode
engine.setApprovalMode(ApprovalMode.AUTO_EDIT);
expect((await engine.check({ name: 'edit' }, undefined)).decision).toBe(
PolicyDecision.ALLOW,
);
// Switch back to default
engine.setApprovalMode(ApprovalMode.DEFAULT);
expect((await engine.check({ name: 'edit' }, undefined)).decision).toBe(
PolicyDecision.ASK_USER,
);
});
});
describe('addRule', () => {
+2 -43
View File
@@ -13,7 +13,6 @@ import {
type HookCheckerRule,
type HookExecutionContext,
getHookSource,
ApprovalMode,
} from './types.js';
import { stableStringify } from './stable-stringify.js';
import { debugLogger } from '../utils/debugLogger.js';
@@ -31,15 +30,7 @@ function ruleMatches(
toolCall: FunctionCall,
stringifiedArgs: string | undefined,
serverName: string | undefined,
currentApprovalMode: ApprovalMode,
): boolean {
// Check if rule applies to current approval mode
if (rule.modes && rule.modes.length > 0) {
if (!rule.modes.includes(currentApprovalMode)) {
return false;
}
}
// Check tool name if specified
if (rule.toolName) {
// Support wildcard patterns: "serverName__*" matches "serverName__anyTool"
@@ -107,7 +98,6 @@ export class PolicyEngine {
private readonly nonInteractive: boolean;
private readonly checkerRunner?: CheckerRunner;
private readonly allowHooks: boolean;
private approvalMode: ApprovalMode;
constructor(config: PolicyEngineConfig = {}, checkerRunner?: CheckerRunner) {
this.rules = (config.rules ?? []).sort(
@@ -123,21 +113,6 @@ export class PolicyEngine {
this.nonInteractive = config.nonInteractive ?? false;
this.checkerRunner = checkerRunner;
this.allowHooks = config.allowHooks ?? true;
this.approvalMode = config.approvalMode ?? ApprovalMode.DEFAULT;
}
/**
* Update the current approval mode.
*/
setApprovalMode(mode: ApprovalMode): void {
this.approvalMode = mode;
}
/**
* Get the current approval mode.
*/
getApprovalMode(): ApprovalMode {
return this.approvalMode;
}
/**
@@ -170,15 +145,7 @@ export class PolicyEngine {
let decision: PolicyDecision | undefined;
for (const rule of this.rules) {
if (
ruleMatches(
rule,
toolCall,
stringifiedArgs,
serverName,
this.approvalMode,
)
) {
if (ruleMatches(rule, toolCall, stringifiedArgs, serverName)) {
debugLogger.debug(
`[PolicyEngine.check] MATCHED rule: toolName=${rule.toolName}, decision=${rule.decision}, priority=${rule.priority}, argsPattern=${rule.argsPattern?.source || 'none'}`,
);
@@ -258,15 +225,7 @@ export class PolicyEngine {
// If decision is not DENY, run safety checkers
if (decision !== PolicyDecision.DENY && this.checkerRunner) {
for (const checkerRule of this.checkers) {
if (
ruleMatches(
checkerRule,
toolCall,
stringifiedArgs,
serverName,
this.approvalMode,
)
) {
if (ruleMatches(checkerRule, toolCall, stringifiedArgs, serverName)) {
debugLogger.debug(
`[PolicyEngine.check] Running safety checker: ${checkerRule.checker.name}`,
);
@@ -1,190 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs/promises';
import { createPolicyUpdater } from './config.js';
import { PolicyEngine } from './policy-engine.js';
import { MessageBus } from '../confirmation-bus/message-bus.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import { Storage } from '../config/storage.js';
import toml from '@iarna/toml';
import { ShellToolInvocation } from '../tools/shell.js';
import { type Config } from '../config/config.js';
import {
ToolConfirmationOutcome,
type PolicyUpdateOptions,
} from '../tools/tools.js';
import * as shellUtils from '../utils/shell-utils.js';
vi.mock('node:fs/promises');
vi.mock('../config/storage.js');
vi.mock('../utils/shell-utils.js', () => ({
getCommandRoots: vi.fn(),
stripShellWrapper: vi.fn(),
}));
interface ParsedPolicy {
rule?: Array<{
commandPrefix?: string | string[];
}>;
}
interface TestableShellToolInvocation {
getPolicyUpdateOptions(
outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined;
}
describe('createPolicyUpdater', () => {
let policyEngine: PolicyEngine;
let messageBus: MessageBus;
beforeEach(() => {
vi.resetAllMocks();
policyEngine = new PolicyEngine({});
vi.spyOn(policyEngine, 'addRule');
messageBus = new MessageBus(policyEngine);
vi.spyOn(Storage, 'getUserPoliciesDir').mockReturnValue(
'/mock/user/policies',
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should add multiple rules when commandPrefix is an array', async () => {
createPolicyUpdater(policyEngine, messageBus);
await messageBus.publish({
type: MessageBusType.UPDATE_POLICY,
toolName: 'run_shell_command',
commandPrefix: ['echo', 'ls'],
persist: false,
});
expect(policyEngine.addRule).toHaveBeenCalledTimes(2);
expect(policyEngine.addRule).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
toolName: 'run_shell_command',
argsPattern: new RegExp('"command":"echo(?:[\\s"]|$)'),
}),
);
expect(policyEngine.addRule).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
toolName: 'run_shell_command',
argsPattern: new RegExp('"command":"ls(?:[\\s"]|$)'),
}),
);
});
it('should add a single rule when commandPrefix is a string', async () => {
createPolicyUpdater(policyEngine, messageBus);
await messageBus.publish({
type: MessageBusType.UPDATE_POLICY,
toolName: 'run_shell_command',
commandPrefix: 'git',
persist: false,
});
expect(policyEngine.addRule).toHaveBeenCalledTimes(1);
expect(policyEngine.addRule).toHaveBeenCalledWith(
expect.objectContaining({
toolName: 'run_shell_command',
argsPattern: new RegExp('"command":"git(?:[\\s"]|$)'),
}),
);
});
it('should persist multiple rules correctly to TOML', async () => {
createPolicyUpdater(policyEngine, messageBus);
vi.mocked(fs.readFile).mockRejectedValue({ code: 'ENOENT' });
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
vi.mocked(fs.rename).mockResolvedValue(undefined);
await messageBus.publish({
type: MessageBusType.UPDATE_POLICY,
toolName: 'run_shell_command',
commandPrefix: ['echo', 'ls'],
persist: true,
});
// Wait for the async listener to complete
await new Promise((resolve) => setTimeout(resolve, 0));
expect(fs.writeFile).toHaveBeenCalled();
const [_path, content] = vi.mocked(fs.writeFile).mock.calls[0] as [
string,
string,
];
const parsed = toml.parse(content) as unknown as ParsedPolicy;
expect(parsed.rule).toHaveLength(1);
expect(parsed.rule![0].commandPrefix).toEqual(['echo', 'ls']);
});
});
describe('ShellToolInvocation Policy Update', () => {
let mockConfig: Config;
let mockMessageBus: MessageBus;
beforeEach(() => {
vi.resetAllMocks();
mockConfig = {} as Config;
mockMessageBus = {} as MessageBus;
vi.mocked(shellUtils.stripShellWrapper).mockImplementation(
(c: string) => c,
);
});
it('should extract multiple root commands for chained commands', () => {
vi.mocked(shellUtils.getCommandRoots).mockReturnValue(['git', 'npm']);
const invocation = new ShellToolInvocation(
mockConfig,
{ command: 'git status && npm test' },
new Set(),
mockMessageBus,
'run_shell_command',
'Shell',
);
// Accessing protected method for testing
const options = (
invocation as unknown as TestableShellToolInvocation
).getPolicyUpdateOptions(ToolConfirmationOutcome.ProceedAlways);
expect(options!.commandPrefix).toEqual(['git', 'npm']);
expect(shellUtils.getCommandRoots).toHaveBeenCalledWith(
'git status && npm test',
);
});
it('should extract a single root command', () => {
vi.mocked(shellUtils.getCommandRoots).mockReturnValue(['ls']);
const invocation = new ShellToolInvocation(
mockConfig,
{ command: 'ls -la /tmp' },
new Set(),
mockMessageBus,
'run_shell_command',
'Shell',
);
// Accessing protected method for testing
const options = (
invocation as unknown as TestableShellToolInvocation
).getPolicyUpdateOptions(ToolConfirmationOutcome.ProceedAlways);
expect(options!.commandPrefix).toEqual(['ls']);
expect(shellUtils.getCommandRoots).toHaveBeenCalledWith('ls -la /tmp');
});
});
@@ -6,7 +6,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { PolicyEngine } from './policy-engine.js';
import { PolicyDecision, ApprovalMode } from './types.js';
import { PolicyDecision } from './types.js';
import type { FunctionCall } from '@google/genai';
describe('Shell Safety Policy', () => {
@@ -25,7 +25,6 @@ describe('Shell Safety Policy', () => {
},
],
defaultDecision: PolicyDecision.ASK_USER,
approvalMode: ApprovalMode.DEFAULT,
});
});
+16 -35
View File
@@ -5,7 +5,7 @@
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { PolicyDecision } from './types.js';
import { ApprovalMode, PolicyDecision } from './types.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import * as os from 'node:os';
@@ -36,7 +36,7 @@ describe('policy-toml-loader', () => {
): Promise<PolicyLoadResult> {
await fs.writeFile(path.join(tempDir, fileName), tomlContent);
const getPolicyTier = (_dir: string) => 1;
return loadPoliciesFromToml([tempDir], getPolicyTier);
return loadPoliciesFromToml(ApprovalMode.DEFAULT, [tempDir], getPolicyTier);
}
describe('loadPoliciesFromToml', () => {
@@ -133,7 +133,7 @@ priority = 100
expect(result.errors).toHaveLength(0);
});
it('should NOT filter rules by mode at load time but preserve modes property', async () => {
it('should filter rules by mode', async () => {
const result = await runLoadPoliciesFromToml(`
[[rule]]
toolName = "glob"
@@ -148,37 +148,10 @@ priority = 100
modes = ["yolo"]
`);
// Both rules should be included
expect(result.rules).toHaveLength(2);
expect(result.rules[0].toolName).toBe('glob');
expect(result.rules[0].modes).toEqual(['default', 'yolo']);
expect(result.rules[1].toolName).toBe('grep');
expect(result.rules[1].modes).toEqual(['yolo']);
expect(result.errors).toHaveLength(0);
});
it('should return error if modes property is used for Tier 2 and Tier 3 policies', async () => {
await fs.writeFile(
path.join(tempDir, 'tier2.toml'),
`
[[rule]]
toolName = "tier2-tool"
decision = "allow"
priority = 100
modes = ["autoEdit"]
`,
);
const getPolicyTier = (_dir: string) => 2; // Tier 2
const result = await loadPoliciesFromToml([tempDir], getPolicyTier);
// It still transforms the rule, but it should also report an error
// Only the first rule should be included (modes includes "default")
expect(result.rules).toHaveLength(1);
expect(result.rules[0].toolName).toBe('tier2-tool');
expect(result.rules[0].modes).toBeUndefined(); // Should be restricted
expect(result.errors).toHaveLength(1);
expect(result.errors[0].errorType).toBe('rule_validation');
expect(result.errors[0].message).toContain('Restricted property "modes"');
expect(result.rules[0].toolName).toBe('glob');
expect(result.errors).toHaveLength(0);
});
it('should handle TOML parse errors', async () => {
@@ -294,7 +267,11 @@ priority = -1
);
const getPolicyTier = (_dir: string) => 1;
const result = await loadPoliciesFromToml([tempDir], getPolicyTier);
const result = await loadPoliciesFromToml(
ApprovalMode.DEFAULT,
[tempDir],
getPolicyTier,
);
expect(result.rules).toHaveLength(1);
expect(result.rules[0].toolName).toBe('glob');
@@ -462,7 +439,11 @@ priority = 100
await fs.writeFile(filePath, 'content');
const getPolicyTier = (_dir: string) => 1;
const result = await loadPoliciesFromToml([filePath], getPolicyTier);
const result = await loadPoliciesFromToml(
ApprovalMode.DEFAULT,
[filePath],
getPolicyTier,
);
expect(result.errors).toHaveLength(1);
const error = result.errors[0];
+20 -38
View File
@@ -7,7 +7,7 @@
import {
type PolicyRule,
PolicyDecision,
ApprovalMode,
type ApprovalMode,
type SafetyCheckerConfig,
type SafetyCheckerRule,
InProcessCheckerType,
@@ -43,7 +43,7 @@ const PolicyRuleSchema = z.object({
message:
'priority must be <= 999 to prevent tier overflow. Priorities >= 1000 would jump to the next tier.',
}),
modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
modes: z.array(z.string()).optional(),
});
/**
@@ -56,7 +56,7 @@ const SafetyCheckerRuleSchema = z.object({
commandPrefix: z.union([z.string(), z.array(z.string())]).optional(),
commandRegex: z.string().optional(),
priority: z.number().int().default(0),
modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
modes: z.array(z.string()).optional(),
checker: z.discriminatedUnion('type', [
z.object({
type: z.literal('in-process'),
@@ -216,13 +216,16 @@ function transformPriority(priority: number, tier: number): number {
* 1. Scans directories for .toml files
* 2. Parses and validates each file
* 3. Transforms rules (commandPrefix, arrays, mcpName, priorities)
* 4. Collects detailed error information for any failures
* 4. Filters rules by approval mode
* 5. Collects detailed error information for any failures
*
* @param approvalMode The current approval mode (for filtering rules by mode)
* @param policyDirs Array of directory paths to scan for policy files
* @param getPolicyTier Function to determine tier (1-3) for a directory
* @returns Object containing successfully parsed rules and any errors encountered
*/
export async function loadPoliciesFromToml(
approvalMode: ApprovalMode,
policyDirs: string[],
getPolicyTier: (dir: string) => number,
): Promise<PolicyLoadResult> {
@@ -302,8 +305,6 @@ export async function loadPoliciesFromToml(
// Validate shell command convenience syntax
const tomlRules = validationResult.data.rule ?? [];
const tomlCheckers = validationResult.data.safety_checker ?? [];
for (let i = 0; i < tomlRules.length; i++) {
const rule = tomlRules[i];
const validationError = validateShellCommandSyntax(rule, i);
@@ -319,40 +320,17 @@ export async function loadPoliciesFromToml(
});
// Continue to next rule, don't skip the entire file
}
if (tier > 1 && rule.modes && rule.modes.length > 0) {
errors.push({
filePath,
fileName: file,
tier: tierName,
ruleIndex: i,
errorType: 'rule_validation',
message: 'Restricted property "modes"',
details: `Rule #${i + 1}: The "modes" property is currently reserved for Tier 1 (system) policies and cannot be used in ${tierName} policies.`,
suggestion: 'Remove the "modes" property from this rule.',
});
}
}
for (let i = 0; i < tomlCheckers.length; i++) {
const checker = tomlCheckers[i];
if (tier > 1 && checker.modes && checker.modes.length > 0) {
errors.push({
filePath,
fileName: file,
tier: tierName,
ruleIndex: i,
errorType: 'rule_validation',
message: 'Restricted property "modes" in safety checker',
details: `Safety Checker #${i + 1}: The "modes" property is currently reserved for Tier 1 (system) policies and cannot be used in ${tierName} policies.`,
suggestion:
'Remove the "modes" property from this safety checker.',
});
}
}
// Transform rules
const parsedRules: PolicyRule[] = (validationResult.data.rule ?? [])
.filter((rule) => {
// Filter by mode
if (!rule.modes || rule.modes.length === 0) {
return true;
}
return rule.modes.includes(approvalMode);
})
.flatMap((rule) => {
// Transform commandPrefix/commandRegex to argsPattern
let effectiveArgsPattern = rule.argsPattern;
@@ -399,7 +377,6 @@ export async function loadPoliciesFromToml(
toolName: effectiveToolName,
decision: rule.decision,
priority: transformPriority(rule.priority, tier),
modes: tier === 1 ? rule.modes : undefined,
};
// Compile regex pattern
@@ -435,6 +412,12 @@ export async function loadPoliciesFromToml(
const parsedCheckers: SafetyCheckerRule[] = (
validationResult.data.safety_checker ?? []
)
.filter((checker) => {
if (!checker.modes || checker.modes.length === 0) {
return true;
}
return checker.modes.includes(approvalMode);
})
.flatMap((checker) => {
let effectiveArgsPattern = checker.argsPattern;
const commandPrefixes: string[] = [];
@@ -476,7 +459,6 @@ export async function loadPoliciesFromToml(
toolName: effectiveToolName,
priority: checker.priority,
checker: checker.checker as SafetyCheckerConfig,
modes: tier === 1 ? checker.modes : undefined,
};
if (argsPattern) {
-18
View File
@@ -117,12 +117,6 @@ export interface PolicyRule {
* Default is 0.
*/
priority?: number;
/**
* Approval modes this rule applies to.
* If undefined or empty, it applies to all modes.
*/
modes?: ApprovalMode[];
}
export interface SafetyCheckerRule {
@@ -149,12 +143,6 @@ export interface SafetyCheckerRule {
* additional validation of a tool call.
*/
checker: SafetyCheckerConfig;
/**
* Approval modes this rule applies to.
* If undefined or empty, it applies to all modes.
*/
modes?: ApprovalMode[];
}
export interface HookExecutionContext {
@@ -227,12 +215,6 @@ export interface PolicyEngineConfig {
* Defaults to true.
*/
allowHooks?: boolean;
/**
* Current approval mode.
* Used to filter rules that have specific 'modes' defined.
*/
approvalMode?: ApprovalMode;
}
export interface PolicySettings {
@@ -7,7 +7,6 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { StartupProfiler } from './startupProfiler.js';
import type { Config } from '../config/config.js';
import { debugLogger } from '../utils/debugLogger.js';
// Mock the metrics module
vi.mock('./metrics.js', () => ({
@@ -256,19 +255,6 @@ describe('StartupProfiler', () => {
}),
);
});
it('should use debug logging instead of standard logging', () => {
const logSpy = vi.spyOn(debugLogger, 'log');
const debugSpy = vi.spyOn(debugLogger, 'debug');
const handle = profiler.start('test_phase');
handle?.end();
profiler.flush(mockConfig);
expect(logSpy).not.toHaveBeenCalled();
expect(debugSpy).toHaveBeenCalled();
});
});
describe('integration scenarios', () => {
@@ -145,7 +145,7 @@ export class StartupProfiler {
* Flushes buffered metrics to the telemetry system.
*/
flush(config: Config): void {
debugLogger.debug(
debugLogger.log(
'[STARTUP] StartupProfiler.flush() called with',
this.phases.size,
'phases',
@@ -181,7 +181,7 @@ export class StartupProfiler {
...phase.details,
};
debugLogger.debug(
debugLogger.log(
'[STARTUP] Recording metric for phase:',
phase.name,
'duration:',
@@ -192,7 +192,7 @@ export class StartupProfiler {
details,
});
} else {
debugLogger.debug(
debugLogger.log(
'[STARTUP] Skipping phase without measure:',
phase.name,
);
@@ -1,188 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EditTool } from './edit.js';
import { SmartEditTool } from './smart-edit.js';
import { WriteFileTool } from './write-file.js';
import { WebFetchTool } from './web-fetch.js';
import { ToolConfirmationOutcome } from './tools.js';
import { ApprovalMode } from '../policy/types.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { Config } from '../config/config.js';
import path from 'node:path';
import fs from 'node:fs';
import os from 'node:os';
// Mock telemetry loggers to avoid failures
vi.mock('../telemetry/loggers.js', () => ({
logSmartEditStrategy: vi.fn(),
logSmartEditCorrectionEvent: vi.fn(),
logFileOperation: vi.fn(),
}));
describe('Tool Confirmation Policy Updates', () => {
let mockConfig: any;
let mockMessageBus: MessageBus;
const rootDir = path.join(
os.tmpdir(),
`gemini-cli-policy-test-${Date.now()}`,
);
beforeEach(() => {
if (!fs.existsSync(rootDir)) {
fs.mkdirSync(rootDir, { recursive: true });
}
mockMessageBus = {
publish: vi.fn(),
subscribe: vi.fn(),
unsubscribe: vi.fn(),
} as unknown as MessageBus;
mockConfig = {
getTargetDir: () => rootDir,
getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT),
setApprovalMode: vi.fn(),
getFileSystemService: () => ({
readTextFile: vi.fn().mockImplementation((p) => {
if (fs.existsSync(p)) {
return fs.readFileSync(p, 'utf8');
}
return 'existing content';
}),
writeTextFile: vi.fn().mockImplementation((p, c) => {
fs.writeFileSync(p, c);
}),
}),
getFileService: () => ({}),
getFileFilteringOptions: () => ({}),
getGeminiClient: () => ({}),
getBaseLlmClient: () => ({}),
getIdeMode: () => false,
getWorkspaceContext: () => ({
isPathWithinWorkspace: () => true,
getDirectories: () => [rootDir],
}),
};
});
afterEach(() => {
if (fs.existsSync(rootDir)) {
fs.rmSync(rootDir, { recursive: true, force: true });
}
vi.restoreAllMocks();
});
const tools = [
{
name: 'EditTool',
create: (config: Config, bus: MessageBus) => new EditTool(config, bus),
params: {
file_path: 'test.txt',
old_string: 'existing',
new_string: 'new',
},
},
{
name: 'SmartEditTool',
create: (config: Config, bus: MessageBus) =>
new SmartEditTool(config, bus),
params: {
file_path: 'test.txt',
instruction: 'change content',
old_string: 'existing',
new_string: 'new',
},
},
{
name: 'WriteFileTool',
create: (config: Config, bus: MessageBus) =>
new WriteFileTool(config, bus),
params: {
file_path: path.join(rootDir, 'test.txt'),
content: 'new content',
},
},
{
name: 'WebFetchTool',
create: (config: Config, bus: MessageBus) =>
new WebFetchTool(config, bus),
params: {
prompt: 'fetch https://example.com',
},
},
];
describe.each(tools)('$name policy updates', ({ create, params }) => {
it.each([
{
outcome: ToolConfirmationOutcome.ProceedAlways,
shouldPublish: false,
expectedApprovalMode: ApprovalMode.AUTO_EDIT,
},
{
outcome: ToolConfirmationOutcome.ProceedAlwaysAndSave,
shouldPublish: true,
persist: true,
},
])(
'should handle $outcome correctly',
async ({ outcome, shouldPublish, persist, expectedApprovalMode }) => {
const tool = create(mockConfig, mockMessageBus);
// For file-based tools, ensure the file exists if needed
if (params.file_path) {
const fullPath = path.isAbsolute(params.file_path)
? params.file_path
: path.join(rootDir, params.file_path);
fs.writeFileSync(fullPath, 'existing content');
}
const invocation = tool.build(params as any);
// Mock getMessageBusDecision to trigger ASK_USER flow
vi.spyOn(invocation as any, 'getMessageBusDecision').mockResolvedValue(
'ASK_USER',
);
const confirmation = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmation).not.toBe(false);
if (confirmation) {
await confirmation.onConfirm(outcome);
if (shouldPublish) {
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
persist,
}),
);
} else {
// Should not publish UPDATE_POLICY message for ProceedAlways
const publishCalls = (mockMessageBus.publish as any).mock.calls;
const hasUpdatePolicy = publishCalls.some(
(call: any) => call[0].type === MessageBusType.UPDATE_POLICY,
);
expect(hasUpdatePolicy).toBe(false);
}
if (expectedApprovalMode !== undefined) {
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
expectedApprovalMode,
);
}
}
},
);
});
});
+1 -4
View File
@@ -311,12 +311,9 @@ class EditToolInvocation
newContent: editData.newContent,
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
// No need to publish a policy update as the default policy for
// AUTO_EDIT already reflects always approving edit.
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
} else {
await this.publishPolicyUpdate(outcome);
}
await this.publishPolicyUpdate(outcome);
if (ideConfirmation) {
const result = await ideConfirmation;
+1 -9
View File
@@ -89,15 +89,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
protected override getPolicyUpdateOptions(
outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
if (
outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave ||
outcome === ToolConfirmationOutcome.ProceedAlways
) {
const command = stripShellWrapper(this.params.command);
const rootCommands = [...new Set(getCommandRoots(command))];
if (rootCommands.length > 0) {
return { commandPrefix: rootCommands };
}
if (outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave) {
return { commandPrefix: this.params.command };
}
return undefined;
+1 -4
View File
@@ -681,12 +681,9 @@ class EditToolInvocation
newContent: editData.newContent,
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
// No need to publish a policy update as the default policy for
// AUTO_EDIT already reflects always approving smart-edit.
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
} else {
await this.publishPolicyUpdate(outcome);
}
await this.publishPolicyUpdate(outcome);
if (ideConfirmation) {
const result = await ideConfirmation;
+1 -1
View File
@@ -69,7 +69,7 @@ export interface ToolInvocation<
* Options for policy updates that can be customized by tool invocations.
*/
export interface PolicyUpdateOptions {
commandPrefix?: string | string[];
commandPrefix?: string;
mcpName?: string;
}
+1 -4
View File
@@ -242,12 +242,9 @@ ${textContent}
urls,
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
// No need to publish a policy update as the default policy for
// AUTO_EDIT already reflects always approving web-fetch.
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
} else {
await this.publishPolicyUpdate(outcome);
}
await this.publishPolicyUpdate(outcome);
},
};
return confirmationDetails;
+1 -4
View File
@@ -222,12 +222,9 @@ class WriteFileToolInvocation extends BaseToolInvocation<
newContent: correctedContent,
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
// No need to publish a policy update as the default policy for
// AUTO_EDIT already reflects always approving write-file.
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
} else {
await this.publishPolicyUpdate(outcome);
}
await this.publishPolicyUpdate(outcome);
if (ideConfirmation) {
const result = await ideConfirmation;
@@ -342,7 +342,7 @@ describe('classifyGoogleError', () => {
const result = classifyGoogleError(originalError);
expect(result).toBeInstanceOf(RetryableQuotaError);
if (result instanceof RetryableQuotaError) {
expect(result.retryDelayMs).toBeUndefined();
expect(result.retryDelayMs).toBe(5000);
}
});
@@ -393,7 +393,7 @@ describe('classifyGoogleError', () => {
}
});
it('should return RetryableQuotaError without delay time for generic 429 without specific message', () => {
it('should return RetryableQuotaError with 5s fallback for generic 429 without specific message', () => {
const generic429 = {
status: 429,
message: 'Resource exhausted. No specific retry info.',
@@ -403,11 +403,11 @@ describe('classifyGoogleError', () => {
expect(result).toBeInstanceOf(RetryableQuotaError);
if (result instanceof RetryableQuotaError) {
expect(result.retryDelayMs).toBeUndefined();
expect(result.retryDelayMs).toBe(5000);
}
});
it('should return RetryableQuotaError without delay time for 429 with empty details and no regex match', () => {
it('should return RetryableQuotaError with 5s fallback for 429 with empty details and no regex match', () => {
const errorWithEmptyDetails = {
error: {
code: 429,
@@ -420,11 +420,11 @@ describe('classifyGoogleError', () => {
expect(result).toBeInstanceOf(RetryableQuotaError);
if (result instanceof RetryableQuotaError) {
expect(result.retryDelayMs).toBeUndefined();
expect(result.retryDelayMs).toBe(5000);
}
});
it('should return RetryableQuotaError without delay time for 429 with some detail', () => {
it('should return RetryableQuotaError with 5s fallback for 429 with some detail', () => {
const errorWithEmptyDetails = {
error: {
code: 429,
@@ -446,7 +446,7 @@ describe('classifyGoogleError', () => {
expect(result).toBeInstanceOf(RetryableQuotaError);
if (result instanceof RetryableQuotaError) {
expect(result.retryDelayMs).toBeUndefined();
expect(result.retryDelayMs).toBe(5000);
}
});
});
+9 -9
View File
@@ -13,6 +13,8 @@ import type {
import { parseGoogleApiError } from './googleErrors.js';
import { getErrorStatus, ModelNotFoundError } from './httpErrors.js';
const DEFAULT_RETRYABLE_DELAY_SECOND = 5;
/**
* A non-retryable error indicating a hard quota limit has been reached (e.g., daily limit).
*/
@@ -22,13 +24,11 @@ export class TerminalQuotaError extends Error {
constructor(
message: string,
override readonly cause: GoogleApiError,
retryDelaySeconds?: number,
retryDelayMs?: number,
) {
super(message);
this.name = 'TerminalQuotaError';
this.retryDelayMs = retryDelaySeconds
? retryDelaySeconds * 1000
: undefined;
this.retryDelayMs = retryDelayMs ? retryDelayMs * 1000 : undefined;
}
}
@@ -36,18 +36,16 @@ export class TerminalQuotaError extends Error {
* A retryable error indicating a temporary quota issue (e.g., per-minute limit).
*/
export class RetryableQuotaError extends Error {
retryDelayMs?: number;
retryDelayMs: number;
constructor(
message: string,
override readonly cause: GoogleApiError,
retryDelaySeconds?: number,
retryDelaySeconds: number,
) {
super(message);
this.name = 'RetryableQuotaError';
this.retryDelayMs = retryDelaySeconds
? retryDelaySeconds * 1000
: undefined;
this.retryDelayMs = retryDelaySeconds * 1000;
}
}
@@ -126,6 +124,7 @@ export function classifyGoogleError(error: unknown): unknown {
message: errorMessage,
details: [],
},
DEFAULT_RETRYABLE_DELAY_SECOND,
);
}
@@ -260,6 +259,7 @@ export function classifyGoogleError(error: unknown): unknown {
message: errorMessage,
details: [],
},
DEFAULT_RETRYABLE_DELAY_SECOND,
);
}
return error; // Fallback to original error if no specific classification fits.
+2 -10
View File
@@ -220,11 +220,6 @@ export async function retryWithBackoff<T>(
if (classifiedError instanceof RetryableQuotaError || is500) {
if (attempt >= maxAttempts) {
const errorMessage =
classifiedError instanceof Error ? classifiedError.message : '';
debugLogger.warn(
`Attempt ${attempt} failed${errorMessage ? `: ${errorMessage}` : ''}. Max attempts reached`,
);
if (onPersistent429) {
try {
const fallbackModel = await onPersistent429(
@@ -245,11 +240,8 @@ export async function retryWithBackoff<T>(
: error;
}
if (
classifiedError instanceof RetryableQuotaError &&
classifiedError.retryDelayMs !== undefined
) {
debugLogger.warn(
if (classifiedError instanceof RetryableQuotaError) {
console.warn(
`Attempt ${attempt} failed: ${classifiedError.message}. Retrying after ${classifiedError.retryDelayMs}ms...`,
);
await delay(classifiedError.retryDelayMs, signal);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.22.5",
"version": "0.22.0",
"private": true,
"main": "src/index.ts",
"license": "Apache-2.0",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "gemini-cli-vscode-ide-companion",
"displayName": "Gemini CLI Companion",
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
"version": "0.22.5",
"version": "0.22.0",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
-7
View File
@@ -1200,13 +1200,6 @@
"default": false,
"type": "boolean"
},
"enablePermanentToolApproval": {
"title": "Allow Permanent Tool Approval",
"description": "Enable the \"Allow for all future sessions\" option in tool confirmation dialogs.",
"markdownDescription": "Enable the \"Allow for all future sessions\" option in tool confirmation dialogs.\n\n- Category: `Security`\n- Requires restart: `no`\n- Default: `false`",
"default": false,
"type": "boolean"
},
"blockGitExtensions": {
"title": "Blocks extensions from Git",
"description": "Blocks installing and loading extensions from Git.",