mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-02 21:21:09 -07:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 994edeb931 |
Binary file not shown.
|
Before Width: | Height: | Size: 387 KiB |
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+7
-7
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+30
-24
@@ -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)
|
||||
"
|
||||
`;
|
||||
|
||||
+8
-39
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface UpdatePolicy {
|
||||
toolName: string;
|
||||
persist?: boolean;
|
||||
argsPattern?: string;
|
||||
commandPrefix?: string | string[];
|
||||
commandPrefix?: string;
|
||||
mcpName?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user