mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-07-24 08:40:58 -07:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87b05ccc0b | |||
| 34668b3c7f | |||
| 3ff055840e | |||
| 42a36294a8 | |||
| ecbab46394 | |||
| 17fb758664 | |||
| 518cc1ab63 | |||
| b7ad7e1035 | |||
| dbcad90661 | |||
| 703d2e0dcc | |||
| 7d8ab08adb | |||
| bc40695ce4 | |||
| 646dc3154b | |||
| 7b772e9dfb | |||
| ef1e18a85a |
+17
-10
@@ -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'],
|
||||
},
|
||||
|
||||
Generated
+21
-28
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface UpdatePolicy {
|
||||
toolName: string;
|
||||
persist?: boolean;
|
||||
argsPattern?: string;
|
||||
commandPrefix?: string;
|
||||
commandPrefix?: string | string[];
|
||||
mcpName?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
|
||||
Reference in New Issue
Block a user