Compare commits

...

15 Commits

Author SHA1 Message Date
gemini-cli-robot 87b05ccc0b chore(release): v0.23.1 2026-01-09 01:57:35 +00:00
Abhi 34668b3c7f Unify shell security policy and remove legacy logic (#15770) 2026-01-08 12:51:53 -08:00
gemini-cli-robot 3ff055840e chore(release): v0.23.0-preview.6 2026-01-07 01:10:50 +00:00
gemini-cli-robot 42a36294a8 fix(patch): cherry-pick 788bb04 to release/v0.23.0-preview.5-pr-15817 [CONFLICTS] (#16038)
Co-authored-by: Sehoon Shon <sshon@google.com>
Co-authored-by: Gal Zahavi <38544478+galz10@users.noreply.github.com>
Co-authored-by: galz10 <galzahavi@google.com>
2026-01-06 16:51:22 -08:00
gemini-cli-robot ecbab46394 chore(release): v0.23.0-preview.5 2026-01-06 23:37:05 +00:00
gemini-cli-robot 17fb758664 fix(patch): cherry-pick c31f053 to release/v0.23.0-preview.4-pr-16004 to patch version v0.23.0-preview.4 and create version 0.23.0-preview.5 (#16027) 2026-01-06 23:13:30 +00:00
gemini-cli-robot 518cc1ab63 chore(release): v0.23.0-preview.4 2025-12-30 23:12:24 +00:00
gemini-cli-robot b7ad7e1035 fix(patch): cherry-pick 07e597d to release/v0.23.0-preview.3-pr-15684 [CONFLICTS] (#15734)
Co-authored-by: Sehoon Shon <sshon@google.com>
2025-12-30 21:23:29 +00:00
gemini-cli-robot dbcad90661 chore(release): v0.23.0-preview.3 2025-12-27 00:40:19 +00:00
gemini-cli-robot 703d2e0dcc fix(patch): cherry-pick 37be162 to release/v0.23.0-preview.2-pr-15601 to patch version v0.23.0-preview.2 and create version 0.23.0-preview.3 (#15603)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com>
2025-12-27 00:16:57 +00:00
gemini-cli-robot 7d8ab08adb chore(release): v0.23.0-preview.2 2025-12-26 18:30:44 +00:00
gemini-cli-robot bc40695ce4 fix(patch): cherry-pick 9cdb267 to release/v0.23.0-preview.1-pr-15494 to patch version v0.23.0-preview.1 and create version 0.23.0-preview.2 (#15592)
Co-authored-by: Sehoon Shon <sshon@google.com>
Co-authored-by: Jack Wotherspoon <jackwoth@google.com>
2025-12-26 18:05:28 +00:00
gemini-cli-robot 646dc3154b chore(release): v0.23.0-preview.1 2025-12-22 22:12:03 +00:00
gemini-cli-robot 7b772e9dfb fix(patch): cherry-pick 0843d9a to release/v0.23.0-preview.0-pr-15443 to patch version v0.23.0-preview.0 and create version 0.23.0-preview.1 (#15445)
Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
2025-12-22 21:36:54 +00:00
gemini-cli-robot ef1e18a85a chore(release): v0.23.0-preview.0 2025-12-22 16:19:52 +00:00
44 changed files with 1694 additions and 379 deletions
+17 -10
View File
@@ -12,7 +12,7 @@ import prettierConfig from 'eslint-config-prettier';
import importPlugin from 'eslint-plugin-import';
import vitest from '@vitest/eslint-plugin';
import globals from 'globals';
import licenseHeader from 'eslint-plugin-license-header';
import headers from 'eslint-plugin-headers';
import path from 'node:path';
import url from 'node:url';
@@ -209,19 +209,26 @@ export default tseslint.config(
{
files: ['./**/*.{tsx,ts,js}'],
plugins: {
'license-header': licenseHeader,
headers,
import: importPlugin,
},
rules: {
'license-header/header': [
'headers/header-format': [
'error',
[
'/**',
' * @license',
' * Copyright 2025 Google LLC',
' * SPDX-License-Identifier: Apache-2.0',
' */',
],
{
source: 'string',
content: [
'@license',
'Copyright (year) Google LLC',
'SPDX-License-Identifier: Apache-2.0',
].join('\n'),
patterns: {
year: {
pattern: '202[5-6]',
defaultValue: '2026',
},
},
},
],
'import/enforce-node-protocol-usage': ['error', 'always'],
},
+21 -28
View File
@@ -1,12 +1,12 @@
{
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.1",
"workspaces": [
"packages/*"
],
@@ -36,8 +36,8 @@
"esbuild-plugin-wasm": "^1.1.0",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-headers": "^1.3.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-license-header": "^0.8.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"glob": "^12.0.0",
@@ -8865,6 +8865,19 @@
"ms": "^2.1.1"
}
},
"node_modules/eslint-plugin-headers": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-headers/-/eslint-plugin-headers-1.3.3.tgz",
"integrity": "sha512-VzZY4+cGRoR5HpALLARH+ibIjB6a2w12/cFEayORHXMRHMzDnweSjpmvxyzX3rsSIVCg01zmvepB7Tnmaj4kGQ==",
"dev": true,
"license": "ISC",
"engines": {
"node": "^16.0.0 || >= 18.0.0"
},
"peerDependencies": {
"eslint": ">=7"
}
},
"node_modules/eslint-plugin-import": {
"version": "2.32.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
@@ -8919,16 +8932,6 @@
"semver": "bin/semver.js"
}
},
"node_modules/eslint-plugin-license-header": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-license-header/-/eslint-plugin-license-header-0.8.0.tgz",
"integrity": "sha512-khTCz6G3JdoQfwrtY4XKl98KW4PpnWUKuFx8v+twIRhJADEyYglMDC0td8It75C1MZ88gcvMusWuUlJsos7gYg==",
"dev": true,
"license": "MIT",
"dependencies": {
"requireindex": "^1.2.0"
}
},
"node_modules/eslint-plugin-react": {
"version": "7.37.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz",
@@ -15069,16 +15072,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/requireindex": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz",
"integrity": "sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.5"
}
},
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
@@ -18473,7 +18466,7 @@
},
"packages/a2a-server": {
"name": "@google/gemini-cli-a2a-server",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.1",
"dependencies": {
"@a2a-js/sdk": "^0.3.7",
"@google-cloud/storage": "^7.16.0",
@@ -18783,7 +18776,7 @@
},
"packages/cli": {
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.1",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "^0.11.0",
@@ -18886,7 +18879,7 @@
},
"packages/core": {
"name": "@google/gemini-cli-core",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.1",
"license": "Apache-2.0",
"dependencies": {
"@google-cloud/logging": "^11.2.1",
@@ -19026,7 +19019,7 @@
},
"packages/test-utils": {
"name": "@google/gemini-cli-test-utils",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.1",
"license": "Apache-2.0",
"devDependencies": {
"typescript": "^5.3.3"
@@ -19037,7 +19030,7 @@
},
"packages/vscode-ide-companion": {
"name": "gemini-cli-vscode-ide-companion",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.1",
"license": "LICENSE",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.23.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.1",
"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.21.0-nightly.20251220.41a1a3eed"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.23.1"
},
"scripts": {
"start": "cross-env NODE_ENV=development node scripts/start.js",
@@ -94,8 +94,8 @@
"esbuild-plugin-wasm": "^1.1.0",
"eslint": "^9.24.0",
"eslint-config-prettier": "^10.1.2",
"eslint-plugin-headers": "^1.3.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-license-header": "^0.8.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"glob": "^12.0.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-a2a-server",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.1",
"description": "Gemini CLI A2A Server",
"repository": {
"type": "git",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.1",
"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.21.0-nightly.20251220.41a1a3eed"
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.23.1"
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.11.0",
+95
View File
@@ -125,6 +125,12 @@ vi.mock('@google/gemini-cli-core', async () => {
respectGitIgnore: true,
respectGeminiIgnore: true,
},
createPolicyEngineConfig: vi.fn(async () => ({
rules: [],
checkers: [],
defaultDecision: ServerConfig.PolicyDecision.ASK_USER,
approvalMode: ServerConfig.ApprovalMode.DEFAULT,
})),
};
});
@@ -2325,3 +2331,92 @@ describe('Telemetry configuration via environment variables', () => {
expect(config.getTelemetryLogPromptsEnabled()).toBe(false);
});
});
describe('PolicyEngine nonInteractive wiring', () => {
const originalIsTTY = process.stdin.isTTY;
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
});
afterEach(() => {
process.stdin.isTTY = originalIsTTY;
vi.restoreAllMocks();
});
it('should set nonInteractive to true in one-shot mode', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js', 'echo hello']; // Positional query makes it one-shot
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig({}, 'test-session', argv);
expect(config.isInteractive()).toBe(false);
expect(
(config.getPolicyEngine() as unknown as { nonInteractive: boolean })
.nonInteractive,
).toBe(true);
});
it('should set nonInteractive to false in interactive mode', async () => {
process.stdin.isTTY = true;
process.argv = ['node', 'script.js'];
const argv = await parseArguments({} as Settings);
const config = await loadCliConfig({}, 'test-session', argv);
expect(config.isInteractive()).toBe(true);
expect(
(config.getPolicyEngine() as unknown as { nonInteractive: boolean })
.nonInteractive,
).toBe(false);
});
});
describe('Policy Engine Integration in loadCliConfig', () => {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
vi.stubEnv('GEMINI_API_KEY', 'test-api-key');
vi.spyOn(ExtensionManager.prototype, 'getExtensions').mockReturnValue([]);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it('should pass merged allowed tools from CLI and settings to createPolicyEngineConfig', async () => {
process.argv = ['node', 'script.js', '--allowed-tools', 'cli-tool'];
const settings: Settings = { tools: { allowed: ['settings-tool'] } };
const argv = await parseArguments({} as Settings);
await loadCliConfig(settings, 'test-session', argv);
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
allowed: expect.arrayContaining(['cli-tool']),
}),
}),
expect.anything(),
);
});
it('should pass merged exclude tools from CLI logic and settings to createPolicyEngineConfig', async () => {
process.stdin.isTTY = false; // Non-interactive to trigger default excludes
process.argv = ['node', 'script.js', '-p', 'test'];
const settings: Settings = { tools: { exclude: ['settings-exclude'] } };
const argv = await parseArguments({} as Settings);
await loadCliConfig(settings, 'test-session', argv);
// In non-interactive mode, ShellTool, etc. are excluded
expect(ServerConfig.createPolicyEngineConfig).toHaveBeenCalledWith(
expect.objectContaining({
tools: expect.objectContaining({
exclude: expect.arrayContaining([SHELL_TOOL_NAME]),
}),
}),
expect.anything(),
);
});
});
+24 -8
View File
@@ -524,23 +524,19 @@ export async function loadCliConfig(
throw err;
}
const policyEngineConfig = await createPolicyEngineConfig(
settings,
approvalMode,
);
const enableMessageBusIntegration =
settings.tools?.enableMessageBusIntegration ?? true;
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
const allowedToolsSet = new Set(allowedTools);
// Interactive mode: explicit -i flag or (TTY + no args + no -p flag)
const hasQuery = !!argv.query;
const interactive =
!!argv.promptInteractive ||
!!argv.experimentalAcp ||
(process.stdin.isTTY && !hasQuery && !argv.prompt);
const allowedTools = argv.allowedTools || settings.tools?.allowed || [];
const allowedToolsSet = new Set(allowedTools);
// In non-interactive mode, exclude tools that require a prompt.
const extraExcludes: string[] = [];
if (!interactive) {
@@ -580,6 +576,26 @@ export async function loadCliConfig(
extraExcludes.length > 0 ? extraExcludes : undefined,
);
// Create a settings object that includes CLI overrides for policy generation
const effectiveSettings: Settings = {
...settings,
tools: {
...settings.tools,
allowed: allowedTools,
exclude: excludeTools,
},
mcp: {
...settings.mcp,
allowed: argv.allowedMcpServerNames ?? settings.mcp?.allowed,
},
};
const policyEngineConfig = await createPolicyEngineConfig(
effectiveSettings,
approvalMode,
);
policyEngineConfig.nonInteractive = !interactive;
const defaultModel = settings.general?.previewFeatures
? PREVIEW_GEMINI_MODEL_AUTO
: DEFAULT_GEMINI_MODEL_AUTO;
@@ -32,6 +32,7 @@ describe('<AppHeader />', () => {
it('should render the banner with default text', () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
@@ -52,6 +53,7 @@ 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',
@@ -72,6 +74,7 @@ describe('<AppHeader />', () => {
it('should not render the banner when no flags are set', () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: '',
warningText: '',
@@ -91,6 +94,7 @@ 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: '',
@@ -111,6 +115,7 @@ 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: '',
@@ -131,6 +136,7 @@ describe('<AppHeader />', () => {
persistentStateMock.get.mockReturnValue(5);
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
@@ -151,6 +157,7 @@ describe('<AppHeader />', () => {
persistentStateMock.get.mockReturnValue({});
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'This is the default banner',
warningText: '',
@@ -177,6 +184,7 @@ describe('<AppHeader />', () => {
it('should render banner text with unescaped newlines', () => {
const mockConfig = makeFakeConfig();
const uiState = {
history: [],
bannerData: {
defaultText: 'First line\\nSecond line',
warningText: '',
@@ -15,6 +15,9 @@ 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(),
}));
@@ -159,7 +162,6 @@ 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);
});
+3 -1
View File
@@ -18,6 +18,7 @@ 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
@@ -47,6 +48,7 @@ export const Header: React.FC<HeaderProps> = ({
}
const artWidth = getAsciiArtWidth(displayTitle);
const title = useSnowfall(displayTitle);
return (
<Box
@@ -55,7 +57,7 @@ export const Header: React.FC<HeaderProps> = ({
flexShrink={0}
flexDirection="column"
>
<ThemedGradient>{displayTitle}</ThemedGradient>
<ThemedGradient>{title}</ThemedGradient>
{nightly && (
<Box width="100%" flexDirection="row" justifyContent="flex-end">
<ThemedGradient>v{version}</ThemedGradient>
@@ -130,10 +130,10 @@ export function useQuotaAndFallback({
isDialogPending.current = false; // Reset the flag here
if (choice === 'retry_always') {
// Explicitly set the model to the fallback model to persist the user's choice.
// Set the model to the fallback model for the current session.
// This ensures the Footer updates and future turns use this model.
config.setModel(proQuotaRequest.fallbackModel);
// The change is not persisted, so the original model is restored on restart.
config.activateFallbackMode(proQuotaRequest.fallbackModel);
historyManager.addItem(
{
type: MessageType.INFO,
@@ -0,0 +1,108 @@
/**
* @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
@@ -0,0 +1,162 @@
/**
* @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.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.1",
"description": "Gemini CLI Core",
"license": "Apache-2.0",
"repository": {
+13 -2
View File
@@ -60,8 +60,11 @@ import { ideContextStore } from '../ide/ideContext.js';
import { WriteTodosTool } from '../tools/write-todos.js';
import type { FileSystemService } from '../services/fileSystemService.js';
import { StandardFileSystemService } from '../services/fileSystemService.js';
import { logRipgrepFallback } from '../telemetry/loggers.js';
import { RipgrepFallbackEvent } from '../telemetry/types.js';
import { logRipgrepFallback, logFlashFallback } from '../telemetry/loggers.js';
import {
RipgrepFallbackEvent,
FlashFallbackEvent,
} from '../telemetry/types.js';
import type { FallbackModelHandler } from '../fallback/types.js';
import { ModelAvailabilityService } from '../availability/modelAvailabilityService.js';
import { ModelRouterService } from '../routing/modelRouterService.js';
@@ -869,6 +872,14 @@ export class Config {
this.modelAvailabilityService.reset();
}
activateFallbackMode(model: string): void {
this.setModel(model);
const authType = this.getContentGeneratorConfig()?.authType;
if (authType) {
logFlashFallback(this, new FlashFallbackEvent(authType));
}
}
getActiveModel(): string {
return this._activeModel ?? this.model;
}
@@ -7,10 +7,16 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Config } from './config.js';
import { DEFAULT_GEMINI_MODEL, DEFAULT_GEMINI_FLASH_MODEL } from './models.js';
import { logFlashFallback } from '../telemetry/loggers.js';
import { FlashFallbackEvent } from '../telemetry/types.js';
import fs from 'node:fs';
vi.mock('node:fs');
vi.mock('../telemetry/loggers.js', () => ({
logFlashFallback: vi.fn(),
logRipgrepFallback: vi.fn(),
}));
describe('Flash Model Fallback Configuration', () => {
let config: Config;
@@ -57,4 +63,15 @@ describe('Flash Model Fallback Configuration', () => {
expect(newConfig.getModel()).toBe('custom-model');
});
});
describe('activateFallbackMode', () => {
it('should set model to fallback and log event', () => {
config.activateFallbackMode(DEFAULT_GEMINI_FLASH_MODEL);
expect(config.getModel()).toBe(DEFAULT_GEMINI_FLASH_MODEL);
expect(logFlashFallback).toHaveBeenCalledWith(
config,
expect.any(FlashFallbackEvent),
);
});
});
});
+1 -1
View File
@@ -41,7 +41,7 @@ export interface UpdatePolicy {
toolName: string;
persist?: boolean;
argsPattern?: string;
commandPrefix?: string;
commandPrefix?: string | string[];
mcpName?: string;
}
+2 -1
View File
@@ -57,6 +57,7 @@ import {
applyModelSelection,
createAvailabilityContextProvider,
} from '../availability/policyHelpers.js';
import { resolveModel } from '../config/models.js';
import type { RetryAvailabilityContext } from '../utils/retry.js';
const MAX_TURNS = 100;
@@ -397,7 +398,7 @@ export class GeminiClient {
// Availability logic: The configured model is the source of truth,
// including any permanent fallbacks (config.setModel) or manual overrides.
return this.config.getActiveModel();
return resolveModel(this.config.getActiveModel());
}
async *sendMessageStream(
+37
View File
@@ -858,4 +858,41 @@ name = "invalid-name"
// Priority 10 in default tier → 1.010
expect(discoveredRule?.priority).toBeCloseTo(1.01, 5);
});
it('should normalize legacy "ShellTool" alias to "run_shell_command"', async () => {
vi.resetModules();
// Mock fs to return empty for policies
const actualFs =
await vi.importActual<typeof import('node:fs/promises')>(
'node:fs/promises',
);
const mockReaddir = vi.fn(
async () => [] as unknown as Awaited<ReturnType<typeof actualFs.readdir>>,
);
vi.doMock('node:fs/promises', () => ({
...actualFs,
default: { ...actualFs, readdir: mockReaddir },
readdir: mockReaddir,
}));
const { createPolicyEngineConfig } = await import('./config.js');
const settings: PolicySettings = {
tools: { allowed: ['ShellTool'] },
};
const config = await createPolicyEngineConfig(
settings,
ApprovalMode.DEFAULT,
'/tmp/mock/default/policies',
);
const rule = config.rules?.find(
(r) =>
r.toolName === 'run_shell_command' &&
r.decision === PolicyDecision.ALLOW,
);
expect(rule).toBeDefined();
expect(rule?.priority).toBeCloseTo(2.3, 5); // Command line allow
vi.doUnmock('node:fs/promises');
});
});
+76 -28
View File
@@ -16,11 +16,8 @@ import {
type PolicySettings,
} from './types.js';
import type { PolicyEngine } from './policy-engine.js';
import {
loadPoliciesFromToml,
type PolicyFileError,
escapeRegex,
} from './toml-loader.js';
import { loadPoliciesFromToml, type PolicyFileError } from './toml-loader.js';
import { buildArgsPatterns } from './utils.js';
import toml from '@iarna/toml';
import {
MessageBusType,
@@ -28,6 +25,8 @@ import {
} from '../confirmation-bus/types.js';
import { type MessageBus } from '../confirmation-bus/message-bus.js';
import { coreEvents } from '../utils/events.js';
import { SHELL_TOOL_NAMES } from '../utils/shell-utils.js';
import { SHELL_TOOL_NAME } from '../tools/tool-names.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -194,11 +193,48 @@ export async function createPolicyEngineConfig(
// Priority: 2.3 (user tier - explicit temporary allows)
if (settings.tools?.allowed) {
for (const tool of settings.tools.allowed) {
rules.push({
toolName: tool,
decision: PolicyDecision.ALLOW,
priority: 2.3,
});
// Check for legacy format: toolName(args)
const match = tool.match(/^([a-zA-Z0-9_-]+)\((.*)\)$/);
if (match) {
const [, rawToolName, args] = match;
// Normalize shell tool aliases
const toolName = SHELL_TOOL_NAMES.includes(rawToolName)
? SHELL_TOOL_NAME
: rawToolName;
// Treat args as a command prefix for shell tool
if (toolName === SHELL_TOOL_NAME) {
const patterns = buildArgsPatterns(undefined, args);
for (const pattern of patterns) {
if (pattern) {
rules.push({
toolName,
decision: PolicyDecision.ALLOW,
priority: 2.3,
argsPattern: new RegExp(pattern),
});
}
}
} else {
// For non-shell tools, we allow the tool itself but ignore args
// as args matching was only supported for shell tools historically.
rules.push({
toolName,
decision: PolicyDecision.ALLOW,
priority: 2.3,
});
}
} else {
// Standard tool name
const toolName = SHELL_TOOL_NAMES.includes(tool)
? SHELL_TOOL_NAME
: tool;
rules.push({
toolName,
decision: PolicyDecision.ALLOW,
priority: 2.3,
});
}
}
}
@@ -244,7 +280,7 @@ interface TomlRule {
mcpName?: string;
decision?: string;
priority?: number;
commandPrefix?: string;
commandPrefix?: string | string[];
argsPattern?: string;
// Index signature to satisfy Record type if needed for toml.stringify
[key: string]: unknown;
@@ -258,26 +294,38 @@ 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 to argsPattern for in-memory rule
// This mimics what toml-loader does
const escapedPrefix = escapeRegex(message.commandPrefix);
argsPattern = new RegExp(`"command":"${escapedPrefix}`);
}
// Convert commandPrefix(es) to argsPatterns for in-memory rules
const patterns = buildArgsPatterns(undefined, message.commandPrefix);
for (const pattern of patterns) {
if (pattern) {
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: new RegExp(pattern),
});
}
}
} 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,
});
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 {
+3 -1
View File
@@ -121,7 +121,9 @@ 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`));
expect(addedRule?.argsPattern).toEqual(
new RegExp(`"command":"git\\ status(?:[\\s"]|$)`),
);
// Verify file written
expect(fs.writeFile).toHaveBeenCalledWith(
+385 -1
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, beforeAll, vi } from 'vitest';
import { PolicyEngine } from './policy-engine.js';
import {
PolicyDecision,
@@ -16,11 +16,40 @@ import {
import type { FunctionCall } from '@google/genai';
import { SafetyCheckDecision } from '../safety/protocol.js';
import type { CheckerRunner } from '../safety/checker-runner.js';
import { initializeShellParsers } from '../utils/shell-utils.js';
import { buildArgsPatterns } from './utils.js';
// Mock shell-utils to ensure consistent behavior across platforms (especially Windows CI)
// We want to test PolicyEngine logic, not the shell parser's ability to parse commands
vi.mock('../utils/shell-utils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../utils/shell-utils.js')>();
return {
...actual,
initializeShellParsers: vi.fn().mockResolvedValue(undefined),
splitCommands: vi.fn().mockImplementation((command: string) => {
// Simple mock splitting logic for test cases
if (command.includes('&&')) {
return command.split('&&').map((c) => c.trim());
}
return [command];
}),
hasRedirection: vi.fn().mockImplementation(
(command: string) =>
// Simple mock: true if '>' is present, unless it looks like "-> arrow"
command.includes('>') && !command.includes('-> arrow'),
),
};
});
describe('PolicyEngine', () => {
let engine: PolicyEngine;
let mockCheckerRunner: CheckerRunner;
beforeAll(async () => {
await initializeShellParsers();
});
beforeEach(() => {
mockCheckerRunner = {
runChecker: vi.fn(),
@@ -418,6 +447,29 @@ describe('PolicyEngine', () => {
);
});
it('should correctly match commands with quotes in commandPrefix', async () => {
const prefix = 'git commit -m "fix"';
const patterns = buildArgsPatterns(undefined, prefix);
const rules: PolicyRule[] = [
{
toolName: 'run_shell_command',
argsPattern: new RegExp(patterns[0]!),
decision: PolicyDecision.ALLOW,
},
];
engine = new PolicyEngine({ rules });
const result = await engine.check(
{
name: 'run_shell_command',
args: { command: 'git commit -m "fix"' },
},
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should handle tools with no args', async () => {
const rules: PolicyRule[] = [
{
@@ -825,6 +877,338 @@ describe('PolicyEngine', () => {
(await engine.check({ name: 'test', args }, undefined)).decision,
).toBe(PolicyDecision.ALLOW);
});
it('should downgrade ALLOW to ASK_USER for redirected shell commands', async () => {
const rules: PolicyRule[] = [
{
toolName: 'run_shell_command',
// Matches "echo" prefix
argsPattern: /"command":"echo/,
decision: PolicyDecision.ALLOW,
},
];
engine = new PolicyEngine({ rules });
// Safe command should be allowed
expect(
(
await engine.check(
{ name: 'run_shell_command', args: { command: 'echo "hello"' } },
undefined,
)
).decision,
).toBe(PolicyDecision.ALLOW);
// Redirected command should be downgraded to ASK_USER
expect(
(
await engine.check(
{
name: 'run_shell_command',
args: { command: 'echo "hello" > file.txt' },
},
undefined,
)
).decision,
).toBe(PolicyDecision.ASK_USER);
});
it('should allow redirected shell commands when allowRedirection is true', async () => {
const rules: PolicyRule[] = [
{
toolName: 'run_shell_command',
// Matches "echo" prefix
argsPattern: /"command":"echo/,
decision: PolicyDecision.ALLOW,
allowRedirection: true,
},
];
engine = new PolicyEngine({ rules });
// Redirected command should stay ALLOW
expect(
(
await engine.check(
{
name: 'run_shell_command',
args: { command: 'echo "hello" > file.txt' },
},
undefined,
)
).decision,
).toBe(PolicyDecision.ALLOW);
});
it('should NOT downgrade ALLOW to ASK_USER for quoted redirection chars', async () => {
const rules: PolicyRule[] = [
{
toolName: 'run_shell_command',
argsPattern: /"command":"echo/,
decision: PolicyDecision.ALLOW,
},
];
engine = new PolicyEngine({ rules });
// Should remain ALLOW because it's not a real redirection
expect(
(
await engine.check(
{
name: 'run_shell_command',
args: { command: 'echo "-> arrow"' },
},
undefined,
)
).decision,
).toBe(PolicyDecision.ALLOW);
});
it('should preserve dir_path during recursive shell command checks', async () => {
const rules: PolicyRule[] = [
{
toolName: 'run_shell_command',
// Rule that only allows echo in a specific directory
// Note: stableStringify sorts keys alphabetically and has no spaces: {"command":"echo hello","dir_path":"/safe/path"}
argsPattern: /"command":"echo hello".*"dir_path":"\/safe\/path"/,
decision: PolicyDecision.ALLOW,
},
{
// Catch-all ALLOW for shell but with low priority
toolName: 'run_shell_command',
decision: PolicyDecision.ALLOW,
priority: -100,
},
];
engine = new PolicyEngine({ rules });
// Compound command. The decomposition will call check() for "echo hello"
// which should match our specific high-priority rule IF dir_path is preserved.
const result = await engine.check(
{
name: 'run_shell_command',
args: { command: 'echo hello && pwd', dir_path: '/safe/path' },
},
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should upgrade ASK_USER to ALLOW if all sub-commands are allowed', async () => {
const rules: PolicyRule[] = [
{
toolName: 'run_shell_command',
argsPattern: /"command":"git status/,
decision: PolicyDecision.ALLOW,
priority: 20,
},
{
toolName: 'run_shell_command',
argsPattern: /"command":"ls/,
decision: PolicyDecision.ALLOW,
priority: 20,
},
{
// Catch-all ASK_USER for shell
toolName: 'run_shell_command',
decision: PolicyDecision.ASK_USER,
priority: 10,
},
];
engine = new PolicyEngine({ rules });
// "git status && ls" matches the catch-all ASK_USER rule initially.
// But since both parts are explicitly ALLOWed, the result should be upgraded to ALLOW.
const result = await engine.check(
{
name: 'run_shell_command',
args: { command: 'git status && ls' },
},
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
it('should respect explicit DENY for compound commands even if parts are allowed', async () => {
const rules: PolicyRule[] = [
{
// Explicitly DENY the compound command
toolName: 'run_shell_command',
argsPattern: /"command":"git status && ls"/,
decision: PolicyDecision.DENY,
priority: 30,
},
{
toolName: 'run_shell_command',
argsPattern: /"command":"git status/,
decision: PolicyDecision.ALLOW,
priority: 20,
},
{
toolName: 'run_shell_command',
argsPattern: /"command":"ls/,
decision: PolicyDecision.ALLOW,
priority: 20,
},
];
engine = new PolicyEngine({ rules });
const result = await engine.check(
{
name: 'run_shell_command',
args: { command: 'git status && ls' },
},
undefined,
);
expect(result.decision).toBe(PolicyDecision.DENY);
});
it('should propagate DENY from any sub-command', async () => {
const rules: PolicyRule[] = [
{
toolName: 'run_shell_command',
argsPattern: /"command":"rm/,
decision: PolicyDecision.DENY,
priority: 20,
},
{
toolName: 'run_shell_command',
argsPattern: /"command":"echo/,
decision: PolicyDecision.ALLOW,
priority: 20,
},
{
toolName: 'run_shell_command',
decision: PolicyDecision.ASK_USER,
priority: 10,
},
];
engine = new PolicyEngine({ rules });
// "echo hello && rm -rf /" -> echo is ALLOW, rm is DENY -> Result DENY
const result = await engine.check(
{
name: 'run_shell_command',
args: { command: 'echo hello && rm -rf /' },
},
undefined,
);
expect(result.decision).toBe(PolicyDecision.DENY);
});
it('should DENY redirected shell commands in non-interactive mode', async () => {
const config: PolicyEngineConfig = {
nonInteractive: true,
rules: [
{
toolName: 'run_shell_command',
decision: PolicyDecision.ALLOW,
},
],
};
engine = new PolicyEngine(config);
// Redirected command should be DENIED in non-interactive mode
// (Normally ASK_USER, but ASK_USER -> DENY in non-interactive)
expect(
(
await engine.check(
{
name: 'run_shell_command',
args: { command: 'echo "hello" > file.txt' },
},
undefined,
)
).decision,
).toBe(PolicyDecision.DENY);
});
it('should default to ASK_USER for atomic commands when matching a wildcard ASK_USER rule', async () => {
// Regression test: atomic commands were auto-allowing because of optimistic initialization
const rules: PolicyRule[] = [
{
toolName: 'run_shell_command',
decision: PolicyDecision.ASK_USER,
},
];
engine = new PolicyEngine({ rules });
// Atomic command "whoami" matches the wildcard rule (ASK_USER).
// It should NOT be upgraded to ALLOW.
expect(
(
await engine.check(
{
name: 'run_shell_command',
args: { command: 'whoami' },
},
undefined,
)
).decision,
).toBe(PolicyDecision.ASK_USER);
});
it('should allow redirected shell commands in non-interactive mode if allowRedirection is true', async () => {
const config: PolicyEngineConfig = {
nonInteractive: true,
rules: [
{
toolName: 'run_shell_command',
decision: PolicyDecision.ALLOW,
allowRedirection: true,
},
],
};
engine = new PolicyEngine(config);
// Redirected command should stay ALLOW even in non-interactive mode
expect(
(
await engine.check(
{
name: 'run_shell_command',
args: { command: 'echo "hello" > file.txt' },
},
undefined,
)
).decision,
).toBe(PolicyDecision.ALLOW);
});
it('should avoid infinite recursion for commands with substitution', async () => {
const rules: PolicyRule[] = [
{
toolName: 'run_shell_command',
decision: PolicyDecision.ALLOW,
},
];
engine = new PolicyEngine({ rules });
// Command with substitution triggers splitCommands returning the same command as its first element.
// This verifies the fix for the infinite recursion bug.
const result = await engine.check(
{
name: 'run_shell_command',
args: { command: 'echo $(ls)' },
},
undefined,
);
expect(result.decision).toBe(PolicyDecision.ALLOW);
});
});
describe('safety checker integration', () => {
+133 -56
View File
@@ -7,6 +7,7 @@
import { type FunctionCall } from '@google/genai';
import {
PolicyDecision,
ApprovalMode,
type PolicyEngineConfig,
type PolicyRule,
type SafetyCheckerRule,
@@ -23,6 +24,7 @@ import {
SHELL_TOOL_NAMES,
initializeShellParsers,
splitCommands,
hasRedirection,
} from '../utils/shell-utils.js';
function ruleMatches(
@@ -98,6 +100,7 @@ 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(
@@ -113,6 +116,126 @@ 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;
}
/**
* Check if a shell command is allowed.
*/
private async checkShellCommand(
toolName: string,
command: string | undefined,
ruleDecision: PolicyDecision,
serverName: string | undefined,
dir_path: string | undefined,
allowRedirection?: boolean,
): Promise<PolicyDecision> {
if (!command) {
return this.applyNonInteractiveMode(ruleDecision);
}
await initializeShellParsers();
const subCommands = splitCommands(command);
if (subCommands.length === 0) {
debugLogger.debug(
`[PolicyEngine.check] Command parsing failed for: ${command}. Falling back to ASK_USER.`,
);
return this.applyNonInteractiveMode(PolicyDecision.ASK_USER);
}
// If there are multiple parts, or if we just want to validate the single part against DENY rules
if (subCommands.length > 0) {
debugLogger.debug(
`[PolicyEngine.check] Validating shell command: ${subCommands.length} parts`,
);
if (ruleDecision === PolicyDecision.DENY) {
return PolicyDecision.DENY;
}
// Start optimistically. If all parts are ALLOW, the whole is ALLOW.
// We will downgrade if any part is ASK_USER or DENY.
let aggregateDecision = PolicyDecision.ALLOW;
for (const subCmd of subCommands) {
// Prevent infinite recursion for the root command
if (subCmd === command) {
if (!allowRedirection && hasRedirection(subCmd)) {
debugLogger.debug(
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${subCmd}`,
);
// Redirection always downgrades ALLOW to ASK_USER
if (aggregateDecision === PolicyDecision.ALLOW) {
aggregateDecision = PolicyDecision.ASK_USER;
}
} else {
// If the command is atomic (cannot be split further) and didn't
// trigger infinite recursion checks, we must respect the decision
// of the rule that triggered this check. If the rule was ASK_USER
// (e.g. wildcard), we must downgrade.
if (
ruleDecision === PolicyDecision.ASK_USER &&
aggregateDecision === PolicyDecision.ALLOW
) {
aggregateDecision = PolicyDecision.ASK_USER;
}
}
continue;
}
const subResult = await this.check(
{ name: toolName, args: { command: subCmd, dir_path } },
serverName,
);
// subResult.decision is already filtered through applyNonInteractiveMode by this.check()
const subDecision = subResult.decision;
// If any part is DENIED, the whole command is DENIED
if (subDecision === PolicyDecision.DENY) {
return PolicyDecision.DENY;
}
// If any part requires ASK_USER, the whole command requires ASK_USER
if (subDecision === PolicyDecision.ASK_USER) {
if (aggregateDecision === PolicyDecision.ALLOW) {
aggregateDecision = PolicyDecision.ASK_USER;
}
}
// Check for redirection in allowed sub-commands
if (
subDecision === PolicyDecision.ALLOW &&
!allowRedirection &&
hasRedirection(subCmd)
) {
debugLogger.debug(
`[PolicyEngine.check] Downgrading ALLOW to ASK_USER for redirected command: ${subCmd}`,
);
if (aggregateDecision === PolicyDecision.ALLOW) {
aggregateDecision = PolicyDecision.ASK_USER;
}
}
}
return this.applyNonInteractiveMode(aggregateDecision);
}
return this.applyNonInteractiveMode(ruleDecision);
}
/**
@@ -150,62 +273,16 @@ export class PolicyEngine {
`[PolicyEngine.check] MATCHED rule: toolName=${rule.toolName}, decision=${rule.decision}, priority=${rule.priority}, argsPattern=${rule.argsPattern?.source || 'none'}`,
);
// Special handling for shell commands: check sub-commands if present
if (
toolCall.name &&
SHELL_TOOL_NAMES.includes(toolCall.name) &&
rule.decision === PolicyDecision.ALLOW
) {
const command = (toolCall.args as { command?: string })?.command;
if (command) {
await initializeShellParsers();
const subCommands = splitCommands(command);
// If there are multiple sub-commands, we must verify EACH of them matches an ALLOW rule.
// If any sub-command results in DENY -> the whole thing is DENY.
// If any sub-command results in ASK_USER -> the whole thing is ASK_USER (unless one is DENY).
// Only if ALL sub-commands are ALLOW do we proceed with ALLOW.
if (subCommands.length === 0) {
// This case occurs if the command is non-empty but parsing fails.
// An ALLOW rule for a prefix might have matched, but since the rest of
// the command is un-parseable, it's unsafe to proceed.
// Fall back to a safe decision.
debugLogger.debug(
`[PolicyEngine.check] Command parsing failed for: ${command}. Falling back to safe decision because implicit ALLOW is unsafe.`,
);
decision = this.applyNonInteractiveMode(PolicyDecision.ASK_USER);
} else if (subCommands.length > 1) {
debugLogger.debug(
`[PolicyEngine.check] Compound command detected: ${subCommands.length} parts`,
);
let aggregateDecision = PolicyDecision.ALLOW;
for (const subCmd of subCommands) {
// Recursively check each sub-command
const subCall = {
name: toolCall.name,
args: { command: subCmd },
};
const subResult = await this.check(subCall, serverName);
if (subResult.decision === PolicyDecision.DENY) {
aggregateDecision = PolicyDecision.DENY;
break; // Fail fast
} else if (subResult.decision === PolicyDecision.ASK_USER) {
aggregateDecision = PolicyDecision.ASK_USER;
// efficient: we can only strictly downgrade from ALLOW to ASK_USER,
// but we must continue looking for DENY.
}
}
decision = aggregateDecision;
} else {
// Single command, rule match is valid
decision = this.applyNonInteractiveMode(rule.decision);
}
} else {
decision = this.applyNonInteractiveMode(rule.decision);
}
if (toolCall.name && SHELL_TOOL_NAMES.includes(toolCall.name)) {
const args = toolCall.args as { command?: string; dir_path?: string };
decision = await this.checkShellCommand(
toolCall.name,
args?.command,
rule.decision,
serverName,
args?.dir_path,
rule.allowRedirection,
);
} else {
decision = this.applyNonInteractiveMode(rule.decision);
}
@@ -0,0 +1,188 @@
/**
* @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' },
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' },
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');
});
});
@@ -154,6 +154,21 @@ modes = ["yolo"]
expect(result.errors).toHaveLength(0);
});
it('should parse and transform allow_redirection property', async () => {
const result = await runLoadPoliciesFromToml(`
[[rule]]
toolName = "run_shell_command"
commandPrefix = "echo"
decision = "allow"
priority = 100
allow_redirection = true
`);
expect(result.rules).toHaveLength(1);
expect(result.rules[0].allowRedirection).toBe(true);
expect(result.errors).toHaveLength(0);
});
it('should handle TOML parse errors', async () => {
const result = await runLoadPoliciesFromToml(`
[[rule]
+16 -51
View File
@@ -7,11 +7,12 @@
import {
type PolicyRule,
PolicyDecision,
type ApprovalMode,
ApprovalMode,
type SafetyCheckerConfig,
type SafetyCheckerRule,
InProcessCheckerType,
} from './types.js';
import { buildArgsPatterns } from './utils.js';
import fs from 'node:fs/promises';
import path from 'node:path';
import toml from '@iarna/toml';
@@ -43,7 +44,8 @@ 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.string()).optional(),
modes: z.array(z.nativeEnum(ApprovalMode)).optional(),
allow_redirection: z.boolean().optional(),
});
/**
@@ -119,17 +121,6 @@ export interface PolicyLoadResult {
errors: PolicyFileError[];
}
/**
* Escapes special regex characters in a string for use in a regex pattern.
* This is used for commandPrefix to ensure literal string matching.
*
* @param str The string to escape
* @returns The escaped string safe for use in a regex
*/
export function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Converts a tier number to a human-readable tier name.
*/
@@ -332,26 +323,11 @@ export async function loadPoliciesFromToml(
return rule.modes.includes(approvalMode);
})
.flatMap((rule) => {
// Transform commandPrefix/commandRegex to argsPattern
let effectiveArgsPattern = rule.argsPattern;
const commandPrefixes: string[] = [];
if (rule.commandPrefix) {
const prefixes = Array.isArray(rule.commandPrefix)
? rule.commandPrefix
: [rule.commandPrefix];
commandPrefixes.push(...prefixes);
} else if (rule.commandRegex) {
effectiveArgsPattern = `"command":"${rule.commandRegex}`;
}
// Expand command prefixes to multiple patterns
const argsPatterns: Array<string | undefined> =
commandPrefixes.length > 0
? commandPrefixes.map(
(prefix) => `"command":"${escapeRegex(prefix)}(?:[\\s"]|$)`,
)
: [effectiveArgsPattern];
const argsPatterns = buildArgsPatterns(
rule.argsPattern,
rule.commandPrefix,
rule.commandRegex,
);
// For each argsPattern, expand toolName arrays
return argsPatterns.flatMap((argsPattern) => {
@@ -377,6 +353,8 @@ export async function loadPoliciesFromToml(
toolName: effectiveToolName,
decision: rule.decision,
priority: transformPriority(rule.priority, tier),
modes: tier === 1 ? rule.modes : undefined,
allowRedirection: rule.allow_redirection,
};
// Compile regex pattern
@@ -419,24 +397,11 @@ export async function loadPoliciesFromToml(
return checker.modes.includes(approvalMode);
})
.flatMap((checker) => {
let effectiveArgsPattern = checker.argsPattern;
const commandPrefixes: string[] = [];
if (checker.commandPrefix) {
const prefixes = Array.isArray(checker.commandPrefix)
? checker.commandPrefix
: [checker.commandPrefix];
commandPrefixes.push(...prefixes);
} else if (checker.commandRegex) {
effectiveArgsPattern = `"command":"${checker.commandRegex}`;
}
const argsPatterns: Array<string | undefined> =
commandPrefixes.length > 0
? commandPrefixes.map(
(prefix) => `"command":"${escapeRegex(prefix)}(?:[\\s"]|$)`,
)
: [effectiveArgsPattern];
const argsPatterns = buildArgsPatterns(
checker.argsPattern,
checker.commandPrefix,
checker.commandRegex,
);
return argsPatterns.flatMap((argsPattern) => {
const toolNames: Array<string | undefined> = checker.toolName
+17
View File
@@ -117,6 +117,18 @@ export interface PolicyRule {
* Default is 0.
*/
priority?: number;
/**
* The approval modes this rule applies to.
* Only used for tier 1 (default) rules.
*/
modes?: ApprovalMode[];
/**
* Whether to allow shell redirection in commands matched by this rule.
* Only applies to shell tools.
*/
allowRedirection?: boolean;
}
export interface SafetyCheckerRule {
@@ -215,6 +227,11 @@ export interface PolicyEngineConfig {
* Defaults to true.
*/
allowHooks?: boolean;
/**
* The current approval mode.
*/
approvalMode?: ApprovalMode;
}
export interface PolicySettings {
+77
View File
@@ -0,0 +1,77 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { escapeRegex, buildArgsPatterns } from './utils.js';
describe('policy/utils', () => {
describe('escapeRegex', () => {
it('should escape special regex characters', () => {
const input = '.-*+?^${}()|[]\\ "';
const escaped = escapeRegex(input);
expect(escaped).toBe(
'\\.\\-\\*\\+\\?\\^\\$\\{\\}\\(\\)\\|\\[\\]\\\\\\ \\"',
);
});
it('should return the same string if no special characters are present', () => {
const input = 'abcABC123';
expect(escapeRegex(input)).toBe(input);
});
});
describe('buildArgsPatterns', () => {
it('should return argsPattern if provided and no commandPrefix/regex', () => {
const result = buildArgsPatterns('my-pattern', undefined, undefined);
expect(result).toEqual(['my-pattern']);
});
it('should build pattern from a single commandPrefix', () => {
const result = buildArgsPatterns(undefined, 'ls', undefined);
expect(result).toEqual(['"command":"ls(?:[\\s"]|$)']);
});
it('should build patterns from an array of commandPrefixes', () => {
const result = buildArgsPatterns(undefined, ['ls', 'cd'], undefined);
expect(result).toEqual([
'"command":"ls(?:[\\s"]|$)',
'"command":"cd(?:[\\s"]|$)',
]);
});
it('should build pattern from commandRegex', () => {
const result = buildArgsPatterns(undefined, undefined, 'rm -rf .*');
expect(result).toEqual(['"command":"rm -rf .*']);
});
it('should prioritize commandPrefix over commandRegex and argsPattern', () => {
const result = buildArgsPatterns('raw', 'prefix', 'regex');
expect(result).toEqual(['"command":"prefix(?:[\\s"]|$)']);
});
it('should prioritize commandRegex over argsPattern if no commandPrefix', () => {
const result = buildArgsPatterns('raw', undefined, 'regex');
expect(result).toEqual(['"command":"regex']);
});
it('should escape characters in commandPrefix', () => {
const result = buildArgsPatterns(undefined, 'git checkout -b', undefined);
expect(result).toEqual(['"command":"git\\ checkout\\ \\-b(?:[\\s"]|$)']);
});
it('should correctly escape quotes in commandPrefix', () => {
const result = buildArgsPatterns(undefined, 'git "fix"', undefined);
expect(result).toEqual([
'"command":"git\\ \\\\\\"fix\\\\\\"(?:[\\s"]|$)',
]);
});
it('should handle undefined correctly when no inputs are provided', () => {
const result = buildArgsPatterns(undefined, undefined, undefined);
expect(result).toEqual([undefined]);
});
});
});
+50
View File
@@ -0,0 +1,50 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Escapes a string for use in a regular expression.
*/
export function escapeRegex(text: string): string {
return text.replace(/[-[\]{}()*+?.,\\^$|#\s"]/g, '\\$&');
}
/**
* Builds a list of args patterns for policy matching.
*
* This function handles the transformation of command prefixes and regexes into
* the internal argsPattern representation used by the PolicyEngine.
*
* @param argsPattern An optional raw regex string for arguments.
* @param commandPrefix An optional command prefix (or list of prefixes) to allow.
* @param commandRegex An optional command regex string to allow.
* @returns An array of string patterns (or undefined) for the PolicyEngine.
*/
export function buildArgsPatterns(
argsPattern?: string,
commandPrefix?: string | string[],
commandRegex?: string,
): Array<string | undefined> {
if (commandPrefix) {
const prefixes = Array.isArray(commandPrefix)
? commandPrefix
: [commandPrefix];
// Expand command prefixes to multiple patterns.
// We append [\\s"] to ensure we match whole words only (e.g., "git" but not
// "github"). Since we match against JSON stringified args, the value is
// always followed by a space or a closing quote.
return prefixes.map((prefix) => {
const jsonPrefix = JSON.stringify(prefix).slice(1, -1);
return `"command":"${escapeRegex(jsonPrefix)}(?:[\\s"]|$)`;
});
}
if (commandRegex) {
return [`"command":"${commandRegex}`];
}
return [argsPattern];
}
@@ -7,6 +7,7 @@
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', () => ({
@@ -255,6 +256,19 @@ 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.log(
debugLogger.debug(
'[STARTUP] StartupProfiler.flush() called with',
this.phases.size,
'phases',
@@ -181,7 +181,7 @@ export class StartupProfiler {
...phase.details,
};
debugLogger.log(
debugLogger.debug(
'[STARTUP] Recording metric for phase:',
phase.name,
'duration:',
@@ -192,7 +192,7 @@ export class StartupProfiler {
details,
});
} else {
debugLogger.log(
debugLogger.debug(
'[STARTUP] Skipping phase without measure:',
phase.name,
);
@@ -24,6 +24,7 @@ export class MockMessageBus {
publishedMessages: Message[] = [];
hookRequests: HookExecutionRequest[] = [];
hookResponses: HookExecutionResponse[] = [];
defaultToolDecision: 'allow' | 'deny' | 'ask_user' = 'allow';
/**
* Mock publish method that captures messages and simulates responses
@@ -50,6 +51,34 @@ export class MockMessageBus {
// Emit response to subscribers
this.emit(MessageBusType.HOOK_EXECUTION_RESPONSE, response);
}
// Handle tool confirmation requests
if (message.type === MessageBusType.TOOL_CONFIRMATION_REQUEST) {
if (this.defaultToolDecision === 'allow') {
this.emit(MessageBusType.TOOL_CONFIRMATION_RESPONSE, {
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: message.correlationId,
confirmed: true,
});
} else if (this.defaultToolDecision === 'deny') {
this.emit(MessageBusType.TOOL_CONFIRMATION_RESPONSE, {
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: message.correlationId,
confirmed: false,
});
} else {
// ask_user
this.emit(MessageBusType.TOOL_CONFIRMATION_RESPONSE, {
type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
correlationId: message.correlationId,
confirmed: false,
requiresUserConfirmation: true,
});
}
}
// Emit the message to subscribers (mimicking real MessageBus behavior)
this.emit(message.type, message);
});
/**
+53 -86
View File
@@ -37,7 +37,6 @@ vi.mock('crypto');
vi.mock('../utils/summarizer.js');
import { initializeShellParsers } from '../utils/shell-utils.js';
import { isCommandAllowed } from '../utils/shell-permissions.js';
import { ShellTool } from './shell.js';
import { type Config } from '../config/config.js';
import {
@@ -55,6 +54,19 @@ import { ToolConfirmationOutcome } from './tools.js';
import { OUTPUT_UPDATE_INTERVAL_MS } from './shell.js';
import { SHELL_TOOL_NAME } from './tool-names.js';
import { WorkspaceContext } from '../utils/workspaceContext.js';
import {
createMockMessageBus,
getMockMessageBusInstance,
} from '../test-utils/mock-message-bus.js';
import {
MessageBusType,
type UpdatePolicy,
} from '../confirmation-bus/types.js';
import { type MessageBus } from '../confirmation-bus/message-bus.js';
interface TestableMockMessageBus extends MessageBus {
defaultToolDecision: 'allow' | 'deny' | 'ask_user';
}
const originalComSpec = process.env['ComSpec'];
const itWindowsOnly = process.platform === 'win32' ? it : it.skip;
@@ -93,7 +105,29 @@ describe('ShellTool', () => {
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
} as unknown as Config;
shellTool = new ShellTool(mockConfig);
const bus = createMockMessageBus();
const mockBus = getMockMessageBusInstance(
bus,
) as unknown as TestableMockMessageBus;
mockBus.defaultToolDecision = 'ask_user';
// Simulate policy update
bus.subscribe(MessageBusType.UPDATE_POLICY, (msg: UpdatePolicy) => {
if (msg.commandPrefix) {
const prefixes = Array.isArray(msg.commandPrefix)
? msg.commandPrefix
: [msg.commandPrefix];
const current = mockConfig.getAllowedTools() || [];
(mockConfig.getAllowedTools as Mock).mockReturnValue([
...current,
...prefixes,
]);
// Simulate Policy Engine allowing the tool after update
mockBus.defaultToolDecision = 'allow';
}
});
shellTool = new ShellTool(mockConfig, bus);
mockPlatform.mockReturnValue('linux');
(vi.mocked(crypto.randomBytes) as Mock).mockReturnValue(
@@ -125,25 +159,6 @@ describe('ShellTool', () => {
}
});
describe('isCommandAllowed', () => {
it('should allow a command if no restrictions are provided', () => {
(mockConfig.getCoreTools as Mock).mockReturnValue(undefined);
(mockConfig.getExcludeTools as Mock).mockReturnValue(undefined);
expect(isCommandAllowed('goodCommand --safe', mockConfig).allowed).toBe(
true,
);
});
it('should allow a command with command substitution using $()', () => {
const evaluation = isCommandAllowed(
'echo $(goodCommand --safe)',
mockConfig,
);
expect(evaluation.allowed).toBe(true);
expect(evaluation.reason).toBeUndefined();
});
});
describe('build', () => {
it('should return an invocation for a valid command', () => {
const invocation = shellTool.build({ command: 'goodCommand --safe' });
@@ -475,6 +490,16 @@ describe('ShellTool', () => {
it('should request confirmation for a new command and allowlist it on "Always"', async () => {
const params = { command: 'npm install' };
const invocation = shellTool.build(params);
// Accessing protected messageBus for testing purposes
const bus = (shellTool as unknown as { messageBus: MessageBus })
.messageBus;
const mockBus = getMockMessageBusInstance(
bus,
) as unknown as TestableMockMessageBus;
// Initially needs confirmation
mockBus.defaultToolDecision = 'ask_user';
const confirmation = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
@@ -482,12 +507,12 @@ describe('ShellTool', () => {
expect(confirmation).not.toBe(false);
expect(confirmation && confirmation.type).toBe('exec');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (confirmation as any).onConfirm(
ToolConfirmationOutcome.ProceedAlways,
);
if (confirmation && confirmation.type === 'exec') {
await confirmation.onConfirm(ToolConfirmationOutcome.ProceedAlways);
}
// Should now be allowlisted
// After "Always", it should be allowlisted in the mock engine
mockBus.defaultToolDecision = 'allow';
const secondInvocation = shellTool.build({ command: 'npm test' });
const secondConfirmation = await secondInvocation.shouldConfirmExecute(
new AbortController().signal,
@@ -498,76 +523,18 @@ describe('ShellTool', () => {
it('should throw an error if validation fails', () => {
expect(() => shellTool.build({ command: '' })).toThrow();
});
describe('in non-interactive mode', () => {
beforeEach(() => {
(mockConfig.isInteractive as Mock).mockReturnValue(false);
});
it('should not throw an error or block for an allowed command', async () => {
(mockConfig.getAllowedTools as Mock).mockReturnValue(['ShellTool(wc)']);
const invocation = shellTool.build({ command: 'wc -l foo.txt' });
const confirmation = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmation).toBe(false);
});
it('should not throw an error or block for an allowed command with arguments', async () => {
(mockConfig.getAllowedTools as Mock).mockReturnValue([
'ShellTool(wc -l)',
]);
const invocation = shellTool.build({ command: 'wc -l foo.txt' });
const confirmation = await invocation.shouldConfirmExecute(
new AbortController().signal,
);
expect(confirmation).toBe(false);
});
it('should throw an error for command that is not allowed', async () => {
(mockConfig.getAllowedTools as Mock).mockReturnValue([
'ShellTool(wc -l)',
]);
const invocation = shellTool.build({ command: 'madeupcommand' });
await expect(
invocation.shouldConfirmExecute(new AbortController().signal),
).rejects.toThrow('madeupcommand');
});
it('should throw an error for a command that is a prefix of an allowed command', async () => {
(mockConfig.getAllowedTools as Mock).mockReturnValue([
'ShellTool(wc -l)',
]);
const invocation = shellTool.build({ command: 'wc' });
await expect(
invocation.shouldConfirmExecute(new AbortController().signal),
).rejects.toThrow('wc');
});
it('should require all segments of a chained command to be allowlisted', async () => {
(mockConfig.getAllowedTools as Mock).mockReturnValue([
'ShellTool(echo)',
]);
const invocation = shellTool.build({ command: 'echo "foo" && ls -l' });
await expect(
invocation.shouldConfirmExecute(new AbortController().signal),
).rejects.toThrow(
'Command "echo "foo" && ls -l" is not in the list of allowed tools for non-interactive mode.',
);
});
});
});
describe('getDescription', () => {
it('should return the windows description when on windows', () => {
mockPlatform.mockReturnValue('win32');
const shellTool = new ShellTool(mockConfig);
const shellTool = new ShellTool(mockConfig, createMockMessageBus());
expect(shellTool.description).toMatchSnapshot();
});
it('should return the non-windows description when not on windows', () => {
mockPlatform.mockReturnValue('linux');
const shellTool = new ShellTool(mockConfig);
const shellTool = new ShellTool(mockConfig, createMockMessageBus());
expect(shellTool.description).toMatchSnapshot();
});
});
+21 -62
View File
@@ -9,7 +9,7 @@ import path from 'node:path';
import os, { EOL } from 'node:os';
import crypto from 'node:crypto';
import type { Config } from '../config/config.js';
import { debugLogger, type AnyToolInvocation } from '../index.js';
import { debugLogger } from '../index.js';
import { ToolErrorType } from './tool-error.js';
import type {
ToolInvocation,
@@ -24,7 +24,6 @@ import {
Kind,
type PolicyUpdateOptions,
} from './tools.js';
import { ApprovalMode } from '../policy/types.js';
import { getErrorMessage } from '../utils/errors.js';
import { summarizeToolOutput } from '../utils/summarizer.js';
@@ -40,10 +39,6 @@ import {
initializeShellParsers,
stripShellWrapper,
} from '../utils/shell-utils.js';
import {
isCommandAllowed,
isShellInvocationAllowlisted,
} from '../utils/shell-permissions.js';
import { SHELL_TOOL_NAME } from './tool-names.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
@@ -62,7 +57,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
constructor(
private readonly config: Config,
params: ShellToolParams,
private readonly allowlist: Set<string>,
messageBus?: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
@@ -89,7 +83,15 @@ export class ShellToolInvocation extends BaseToolInvocation<
protected override getPolicyUpdateOptions(
outcome: ToolConfirmationOutcome,
): PolicyUpdateOptions | undefined {
if (outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave) {
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 };
}
return { commandPrefix: this.params.command };
}
return undefined;
@@ -99,43 +101,26 @@ export class ShellToolInvocation extends BaseToolInvocation<
_abortSignal: AbortSignal,
): Promise<ToolCallConfirmationDetails | false> {
const command = stripShellWrapper(this.params.command);
const rootCommands = [...new Set(getCommandRoots(command))];
let rootCommands = [...new Set(getCommandRoots(command))];
// In non-interactive mode, we need to prevent the tool from hanging while
// waiting for user input. If a tool is not fully allowed (e.g. via
// --allowed-tools="ShellTool(wc)"), we should throw an error instead of
// prompting for confirmation. This check is skipped in YOLO mode.
if (
!this.config.isInteractive() &&
this.config.getApprovalMode() !== ApprovalMode.YOLO
) {
if (this.isInvocationAllowlisted(command)) {
// If it's an allowed shell command, we don't need to confirm execution.
return false;
// Fallback for UI display if parser fails or returns no commands (e.g.
// variable assignments only)
if (rootCommands.length === 0 && command.trim()) {
const fallback = command.trim().split(/\s+/)[0];
if (fallback) {
rootCommands = [fallback];
}
throw new Error(
`Command "${command}" is not in the list of allowed tools for non-interactive mode.`,
);
}
const commandsToConfirm = rootCommands.filter(
(command) => !this.allowlist.has(command),
);
if (commandsToConfirm.length === 0) {
return false; // already approved and allowlisted
}
// Rely entirely on PolicyEngine for interactive confirmation.
// If we are here, it means PolicyEngine returned ASK_USER (or no message bus),
// so we must provide confirmation details.
const confirmationDetails: ToolExecuteConfirmationDetails = {
type: 'exec',
title: 'Confirm Shell Command',
command: this.params.command,
rootCommand: commandsToConfirm.join(', '),
rootCommand: rootCommands.join(', '),
onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
commandsToConfirm.forEach((command) => this.allowlist.add(command));
}
await this.publishPolicyUpdate(outcome);
},
};
@@ -389,16 +374,6 @@ export class ShellToolInvocation extends BaseToolInvocation<
}
}
}
private isInvocationAllowlisted(command: string): boolean {
const allowedTools = this.config.getAllowedTools() || [];
if (allowedTools.length === 0) {
return false;
}
const invocation = { params: { command } } as unknown as AnyToolInvocation;
return isShellInvocationAllowlisted(invocation, allowedTools);
}
}
function getShellToolDescription(): string {
@@ -437,8 +412,6 @@ export class ShellTool extends BaseDeclarativeTool<
> {
static readonly Name = SHELL_TOOL_NAME;
private allowlist: Set<string> = new Set();
constructor(
private readonly config: Config,
messageBus?: MessageBus,
@@ -484,19 +457,6 @@ export class ShellTool extends BaseDeclarativeTool<
return 'Command cannot be empty.';
}
const commandCheck = isCommandAllowed(params.command, this.config);
if (!commandCheck.allowed) {
if (!commandCheck.reason) {
debugLogger.error(
'Unexpected: isCommandAllowed returned false without a reason',
);
return `Command is not allowed: ${params.command}`;
}
return commandCheck.reason;
}
if (getCommandRoots(params.command).length === 0) {
return 'Could not identify command root to obtain permission from user.';
}
if (params.dir_path) {
const resolvedPath = path.resolve(
this.config.getTargetDir(),
@@ -519,7 +479,6 @@ export class ShellTool extends BaseDeclarativeTool<
return new ShellToolInvocation(
this.config,
params,
this.allowlist,
messageBus,
_toolName,
_toolDisplayName,
+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;
commandPrefix?: string | string[];
mcpName?: string;
}
@@ -342,7 +342,7 @@ describe('classifyGoogleError', () => {
const result = classifyGoogleError(originalError);
expect(result).toBeInstanceOf(RetryableQuotaError);
if (result instanceof RetryableQuotaError) {
expect(result.retryDelayMs).toBe(5000);
expect(result.retryDelayMs).toBeUndefined();
}
});
@@ -393,7 +393,7 @@ describe('classifyGoogleError', () => {
}
});
it('should return RetryableQuotaError with 5s fallback for generic 429 without specific message', () => {
it('should return RetryableQuotaError without delay time 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).toBe(5000);
expect(result.retryDelayMs).toBeUndefined();
}
});
it('should return RetryableQuotaError with 5s fallback for 429 with empty details and no regex match', () => {
it('should return RetryableQuotaError without delay time 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).toBe(5000);
expect(result.retryDelayMs).toBeUndefined();
}
});
it('should return RetryableQuotaError with 5s fallback for 429 with some detail', () => {
it('should return RetryableQuotaError without delay time 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).toBe(5000);
expect(result.retryDelayMs).toBeUndefined();
}
});
});
+9 -9
View File
@@ -13,8 +13,6 @@ 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).
*/
@@ -24,11 +22,13 @@ export class TerminalQuotaError extends Error {
constructor(
message: string,
override readonly cause: GoogleApiError,
retryDelayMs?: number,
retryDelaySeconds?: number,
) {
super(message);
this.name = 'TerminalQuotaError';
this.retryDelayMs = retryDelayMs ? retryDelayMs * 1000 : undefined;
this.retryDelayMs = retryDelaySeconds
? retryDelaySeconds * 1000
: undefined;
}
}
@@ -36,16 +36,18 @@ 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 * 1000;
this.retryDelayMs = retryDelaySeconds
? retryDelaySeconds * 1000
: undefined;
}
}
@@ -124,7 +126,6 @@ export function classifyGoogleError(error: unknown): unknown {
message: errorMessage,
details: [],
},
DEFAULT_RETRYABLE_DELAY_SECOND,
);
}
@@ -259,7 +260,6 @@ export function classifyGoogleError(error: unknown): unknown {
message: errorMessage,
details: [],
},
DEFAULT_RETRYABLE_DELAY_SECOND,
);
}
return error; // Fallback to original error if no specific classification fits.
+10 -2
View File
@@ -220,6 +220,11 @@ 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(
@@ -240,8 +245,11 @@ export async function retryWithBackoff<T>(
: error;
}
if (classifiedError instanceof RetryableQuotaError) {
console.warn(
if (
classifiedError instanceof RetryableQuotaError &&
classifiedError.retryDelayMs !== undefined
) {
debugLogger.warn(
`Attempt ${attempt} failed: ${classifiedError.message}. Retrying after ${classifiedError.retryDelayMs}ms...`,
);
await delay(classifiedError.retryDelayMs, signal);
+23
View File
@@ -619,3 +619,26 @@ export const spawnAsync = (
reject(err);
});
});
/**
* Detects if a shell command contains any redirection or piping operators.
* This is used for safety checks to prevent unauthorized file writes or data exfiltration.
*
* @param command The shell command to check.
* @returns true if redirection or piping is detected.
*/
export function hasRedirection(command: string): boolean {
if (!command) return false;
// Check for common redirection and piping operators: >, >>, <, |, <<, <<<, &>, &>>
// We use a regex that looks for these operators while trying to avoid false positives
// in strings (though this is a heuristic).
const redirectionRegex = /(?:\s|^)(?:[0-2]?>{1,2}|<|\|{1,2}|&>{1,2})(?:\s|$)/;
// Also check for bash-specific process substitution
const processSubstitutionRegex = /(?:<|>)\(/;
return (
redirectionRegex.test(command) || processSubstitutionRegex.test(command)
);
}
@@ -123,8 +123,26 @@ describe('calculateRequestTokenCount', () => {
// Should fallback to estimation:
// 'Hello': 5 chars * 0.25 = 1.25
// inlineData: JSON.stringify length / 4
expect(count).toBeGreaterThan(0);
// inlineData: 3000
// Total: 3001.25 -> 3001
expect(count).toBe(3001);
expect(mockContentGenerator.countTokens).toHaveBeenCalled();
});
it('should use fixed estimate for images in fallback', async () => {
vi.mocked(mockContentGenerator.countTokens).mockRejectedValue(
new Error('API error'),
);
const request = [
{ inlineData: { mimeType: 'image/png', data: 'large_data' } },
];
const count = await calculateRequestTokenCount(
request,
mockContentGenerator,
model,
);
expect(count).toBe(3000);
});
});
+20 -5
View File
@@ -6,6 +6,7 @@
import type { PartListUnion, Part } from '@google/genai';
import type { ContentGenerator } from '../core/contentGenerator.js';
import { debugLogger } from './debugLogger.js';
// Token estimation constants
// ASCII characters (0-127) are roughly 4 chars per token
@@ -13,6 +14,8 @@ const ASCII_TOKENS_PER_CHAR = 0.25;
// Non-ASCII characters (including CJK) are often 1-2 tokens per char.
// We use 1.3 as a conservative estimate to avoid underestimation.
const NON_ASCII_TOKENS_PER_CHAR = 1.3;
// Fixed token estimate for images
const IMAGE_TOKEN_ESTIMATE = 3000;
/**
* Estimates token count for parts synchronously using a heuristic.
@@ -31,10 +34,21 @@ export function estimateTokenCountSync(parts: Part[]): number {
}
}
} else {
// For non-text parts (functionCall, functionResponse, executableCode, etc.),
// we fallback to the JSON string length heuristic.
// Note: This is an approximation.
totalTokens += JSON.stringify(part).length / 4;
// For images, we use a fixed safe estimate (3,000 tokens) covering
// up to 4K resolution on Gemini 3.
// See: https://ai.google.dev/gemini-api/docs/vision#token_counting
const inlineData = 'inlineData' in part ? part.inlineData : undefined;
const fileData = 'fileData' in part ? part.fileData : undefined;
const mimeType = inlineData?.mimeType || fileData?.mimeType;
if (mimeType?.startsWith('image/')) {
totalTokens += IMAGE_TOKEN_ESTIMATE;
} else {
// For other non-text parts (functionCall, functionResponse, etc.),
// we fallback to the JSON string length heuristic.
// Note: This is an approximation.
totalTokens += JSON.stringify(part).length / 4;
}
}
}
return Math.floor(totalTokens);
@@ -69,8 +83,9 @@ export async function calculateRequestTokenCount(
contents: [{ role: 'user', parts }],
});
return response.totalTokens ?? 0;
} catch {
} catch (error) {
// Fallback to local estimation if the API call fails
debugLogger.debug('countTokens API failed:', error);
return estimateTokenCountSync(parts);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@google/gemini-cli-test-utils",
"version": "0.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.1",
"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.21.0-nightly.20251220.41a1a3eed",
"version": "0.23.1",
"publisher": "google",
"icon": "assets/icon.png",
"repository": {
+1 -1
View File
@@ -57,7 +57,7 @@ try {
const fileContent = `/**
* @license
* Copyright ${new Date().getFullYear()} Google LLC
* Copyright ${new Date().getUTCFullYear()} Google LLC
* SPDX-License-Identifier: Apache-2.0
*/