Compare commits

..

9 Commits

50 changed files with 2584 additions and 1463 deletions
+5 -14
View File
@@ -19,24 +19,15 @@ Use the following command in Gemini CLI:
Running this command will open a dialog with your options:
| Option | Description | Models |
| ----------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------- |
| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview (if enabled), gemini-3-flash-preview (if enabled) |
| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash |
| Manual | Select a specific model. | Any available model. |
| Option | Description | Models |
| ----------------- | -------------------------------------------------------------- | -------------------------------------------- |
| Auto (Gemini 3) | Let the system choose the best Gemini 3 model for your task. | gemini-3-pro-preview, gemini-3-flash-preview |
| Auto (Gemini 2.5) | Let the system choose the best Gemini 2.5 model for your task. | gemini-2.5-pro, gemini-2.5-flash |
| Manual | Select a specific model. | Any available model. |
We recommend selecting one of the above **Auto** options. However, you can
select **Manual** to select a specific model from those available.
### Gemini 3 and preview features
> **Note:** Gemini 3 is not currently available on all account types. To learn
> more about Gemini 3 access, refer to
> [Gemini 3 on Gemini CLI](../get-started/gemini-3.md).
To enable Gemini 3 Pro and Gemini 3 Flash (if available), enable
[**Preview Features** by using the `settings` command](../cli/settings.md).
You can also use the `--model` flag to specify a particular Gemini model on
startup. For more details, refer to the
[configuration documentation](../reference/configuration.md).
-1
View File
@@ -125,7 +125,6 @@ they appear in the UI.
| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- |
| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` |
| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` |
| Auto-add to Policy | `security.autoAddPolicy` | Automatically add "Proceed always" approvals to your persistent policy. | `true` |
| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` |
| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` |
| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `true` |
-5
View File
@@ -866,11 +866,6 @@ their corresponding top-level category object in your `settings.json` file.
confirmation dialogs.
- **Default:** `false`
- **`security.autoAddPolicy`** (boolean):
- **Description:** Automatically add "Proceed always" approvals to your
persistent policy.
- **Default:** `true`
- **`security.blockGitExtensions`** (boolean):
- **Description:** Blocks installing and loading extensions from Git.
- **Default:** `false`
+7 -20
View File
@@ -55,26 +55,8 @@ export default tseslint.config(
},
},
{
// Import specific config
files: ['packages/*/src/**/*.{ts,tsx}'], // Target all TS/TSX in the packages
plugins: {
import: importPlugin,
},
settings: {
'import/resolver': {
node: true,
},
},
rules: {
...importPlugin.configs.recommended.rules,
...importPlugin.configs.typescript.rules,
'import/no-default-export': 'warn',
'import/no-unresolved': 'off', // Disable for now, can be noisy with monorepos/paths
},
},
{
// General overrides and rules for the project (TS/TSX files)
files: ['packages/*/src/**/*.{ts,tsx}'], // Target only TS/TSX in the cli package
// Rules for packages/*/src (TS/TSX)
files: ['packages/*/src/**/*.{ts,tsx}'],
plugins: {
import: importPlugin,
},
@@ -95,6 +77,11 @@ export default tseslint.config(
},
},
rules: {
...importPlugin.configs.recommended.rules,
...importPlugin.configs.typescript.rules,
'import/no-default-export': 'warn',
'import/no-unresolved': 'off',
'import/no-duplicates': 'error',
// General Best Practice Rules (subset adapted for flat config)
'@typescript-eslint/array-type': ['error', { default: 'array-simple' }],
'arrow-body-style': ['error', 'as-needed'],
+1 -1
View File
@@ -88,7 +88,7 @@ describe('Answer vs. ask eval', () => {
* Ensures that when the user asks a general question, the agent does NOT
* automatically modify the file.
*/
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: 'should not edit files when asked a general question',
prompt: 'How does app.ts work?',
files: FILES,
+1 -1
View File
@@ -25,7 +25,7 @@ describe('git repo eval', () => {
* The phrasing is intentionally chosen to evoke 'complete' to help the test
* be more consistent.
*/
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: 'should not git add commit changes unprompted',
prompt:
'Finish this up for me by just making a targeted fix for the bug in index.ts. Do not build, install anything, or add tests',
+1 -1
View File
@@ -86,7 +86,7 @@ Provide the answer as an XML block like this:
});
const extensionVsGlobalTest = 'Extension memory wins over Global memory';
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: extensionVsGlobalTest,
params: {
settings: {
+2 -2
View File
@@ -18,7 +18,7 @@ describe('plan_mode', () => {
experimental: { plan: true },
};
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: 'should refuse file modification when in plan mode',
approvalMode: ApprovalMode.PLAN,
params: {
@@ -57,7 +57,7 @@ describe('plan_mode', () => {
},
});
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: 'should refuse saving new documentation to the repo when in plan mode',
approvalMode: ApprovalMode.PLAN,
params: {
+3 -3
View File
@@ -125,7 +125,7 @@ describe('save_memory', () => {
});
const rememberingCommandAlias = 'Agent remembers custom command aliases';
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: rememberingCommandAlias,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -178,7 +178,7 @@ describe('save_memory', () => {
const rememberingCodingStyle =
"Agent remembers user's coding style preference";
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: rememberingCodingStyle,
params: {
settings: { tools: { core: ['save_memory'] } },
@@ -260,7 +260,7 @@ describe('save_memory', () => {
});
const rememberingBirthday = "Agent remembers user's birthday";
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: rememberingBirthday,
params: {
settings: { tools: { core: ['save_memory'] } },
+1 -1
View File
@@ -72,7 +72,7 @@ describe('Shell Efficiency', () => {
},
});
evalTest('USUALLY_PASSES', {
evalTest('ALWAYS_PASSES', {
name: 'should NOT use efficiency flags when enableShellOutputEfficiency is disabled',
params: {
settings: {
+5 -6
View File
@@ -12,23 +12,22 @@ import type {
RequestContext,
ExecutionEventBus,
} from '@a2a-js/sdk/server';
import type { ToolCallRequestInfo, Config } from '@google/gemini-cli-core';
import {
GeminiEventType,
SimpleExtensionLoader,
type ToolCallRequestInfo,
type Config,
} from '@google/gemini-cli-core';
import { v4 as uuidv4 } from 'uuid';
import { logger } from '../utils/logger.js';
import type {
StateChange,
AgentSettings,
PersistedStateMetadata,
} from '../types.js';
import {
CoderAgentEvent,
getPersistedState,
setPersistedState,
type StateChange,
type AgentSettings,
type PersistedStateMetadata,
getContextIdFromMetadata,
getAgentSettingsFromMetadata,
} from '../types.js';
+5 -7
View File
@@ -14,17 +14,15 @@ import {
type Mock,
} from 'vitest';
import { Task } from './task.js';
import type {
ToolCall,
Config,
ToolCallRequestInfo,
GitService,
CompletedToolCall,
} from '@google/gemini-cli-core';
import {
GeminiEventType,
ApprovalMode,
ToolConfirmationOutcome,
type Config,
type ToolCallRequestInfo,
type GitService,
type CompletedToolCall,
type ToolCall,
} from '@google/gemini-cli-core';
import { createMockConfig } from '../utils/testing_utils.js';
import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server';
+14 -11
View File
@@ -31,7 +31,10 @@ import {
EDIT_TOOL_NAMES,
processRestorableToolCalls,
} from '@google/gemini-cli-core';
import type { RequestContext, ExecutionEventBus } from '@a2a-js/sdk/server';
import {
type ExecutionEventBus,
type RequestContext,
} from '@a2a-js/sdk/server';
import type {
TaskStatusUpdateEvent,
TaskArtifactUpdateEvent,
@@ -44,16 +47,16 @@ import { v4 as uuidv4 } from 'uuid';
import { logger } from '../utils/logger.js';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { CoderAgentEvent } from '../types.js';
import type {
CoderAgentMessage,
StateChange,
ToolCallUpdate,
TextContent,
TaskMetadata,
Thought,
ThoughtSummary,
Citation,
import {
CoderAgentEvent,
type CoderAgentMessage,
type StateChange,
type ToolCallUpdate,
type TextContent,
type TaskMetadata,
type Thought,
type ThoughtSummary,
type Citation,
} from '../types.js';
import type { PartUnion, Part as genAiPart } from '@google/genai';
@@ -6,7 +6,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { InitCommand } from './init.js';
import { performInit } from '@google/gemini-cli-core';
import {
performInit,
type CommandActionReturn,
type Config,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { CoderAgentExecutor } from '../agent/executor.js';
@@ -14,7 +18,6 @@ import { CoderAgentEvent } from '../types.js';
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
import { createMockConfig } from '../utils/testing_utils.js';
import type { CommandContext } from './types.js';
import type { CommandActionReturn, Config } from '@google/gemini-cli-core';
import { logger } from '../utils/logger.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
@@ -9,6 +9,9 @@ import {
listMemoryFiles,
refreshMemory,
showMemory,
type AnyDeclarativeTool,
type Config,
type ToolRegistry,
} from '@google/gemini-cli-core';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
@@ -19,11 +22,6 @@ import {
ShowMemoryCommand,
} from './memory.js';
import type { CommandContext } from './types.js';
import type {
AnyDeclarativeTool,
Config,
ToolRegistry,
} from '@google/gemini-cli-core';
// Mock the core functions
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+3 -5
View File
@@ -8,11 +8,6 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import * as dotenv from 'dotenv';
import type {
TelemetryTarget,
ConfigParameters,
ExtensionLoader,
} from '@google/gemini-cli-core';
import {
AuthType,
Config,
@@ -28,6 +23,9 @@ import {
fetchAdminControlsOnce,
getCodeAssistServer,
ExperimentFlags,
type TelemetryTarget,
type ConfigParameters,
type ExtensionLoader,
} from '@google/gemini-cli-core';
import { logger } from '../utils/logger.js';
+5 -4
View File
@@ -4,11 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type {
Config,
ToolCallConfirmationDetails,
import {
GeminiEventType,
ApprovalMode,
type Config,
type ToolCallConfirmationDetails,
} from '@google/gemini-cli-core';
import { GeminiEventType, ApprovalMode } from '@google/gemini-cli-core';
import type {
TaskStatusUpdateEvent,
SendStreamingMessageSuccessResponse,
@@ -11,8 +11,16 @@ import { gzipSync, gunzipSync } from 'node:zlib';
import { v4 as uuidv4 } from 'uuid';
import type { Task as SDKTask } from '@a2a-js/sdk';
import type { TaskStore } from '@a2a-js/sdk/server';
import type { Mocked, MockedClass, Mock } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
describe,
it,
expect,
beforeEach,
vi,
type Mocked,
type MockedClass,
type Mock,
} from 'vitest';
import { GCSTaskStore, NoOpTaskStore } from './gcs.js';
import { logger } from '../utils/logger.js';
@@ -8,8 +8,7 @@ import type { Message } from '@a2a-js/sdk';
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
import { v4 as uuidv4 } from 'uuid';
import { CoderAgentEvent } from '../types.js';
import type { StateChange } from '../types.js';
import { CoderAgentEvent, type StateChange } from '../types.js';
export async function pushTaskStateFailed(
error: unknown,
@@ -18,9 +18,10 @@ import {
HookSystem,
PolicyDecision,
tmpdir,
type Config,
type Storage,
} from '@google/gemini-cli-core';
import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js';
import type { Config, Storage } from '@google/gemini-cli-core';
import { expect, vi } from 'vitest';
export function createMockConfig(
+2 -2
View File
@@ -818,10 +818,9 @@ export async function loadCliConfig(
model: resolvedModel,
maxSessionTurns: settings.model?.maxSessionTurns,
experimentalZedIntegration: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false,
listSessions: argv.listSessions || false,
deleteSession: argv.deleteSession,
autoAddPolicy:
settings.security?.autoAddPolicy && !settings.admin?.secureModeEnabled,
enabledExtensions: argv.extensions,
extensionLoader: extensionManager,
enableExtensionReloading: settings.experimental?.extensionReloading,
@@ -844,6 +843,7 @@ export async function loadCliConfig(
interactive,
trustedFolder,
useBackgroundColor: settings.ui?.useBackgroundColor,
useAlternateBuffer: settings.ui?.useAlternateBuffer,
useRipgrep: settings.tools?.useRipgrep,
enableInteractiveShell: settings.tools?.shell?.enableInteractiveShell,
shellToolInactivityTimeout: settings.tools?.shell?.inactivityTimeout,
-10
View File
@@ -1480,16 +1480,6 @@ const SETTINGS_SCHEMA = {
'Enable the "Allow for all future sessions" option in tool confirmation dialogs.',
showInDialog: true,
},
autoAddPolicy: {
type: 'boolean',
label: 'Auto-add to Policy',
category: 'Security',
requiresRestart: false,
default: true,
description:
'Automatically add "Proceed always" approvals to your persistent policy.',
showInDialog: true,
},
blockGitExtensions: {
type: 'boolean',
label: 'Blocks extensions from Git',
-7
View File
@@ -594,13 +594,6 @@ export async function main() {
const messageBus = config.getMessageBus();
createPolicyUpdater(policyEngine, messageBus, config.storage);
// Listen for settings changes to update reactive config properties
coreEvents.on(CoreEvent.SettingsChanged, () => {
if (settings.merged.security.autoAddPolicy !== undefined) {
config.setAutoAddPolicy(settings.merged.security.autoAddPolicy);
}
});
// Register SessionEnd hook to fire on graceful exit
// This runs before telemetry shutdown in runExitCleanup()
registerCleanup(async () => {
@@ -11,6 +11,50 @@ Enter to submit · Esc to cancel
"
`;
exports[`AskUserDialog > Choice question placeholder > uses default placeholder when not provided 2`] = `
"
ERROR Cannot read properties of undefined (reading '$$typeof')
/Users/spencertang/Workspace/gemini-cli/node_modules/react/cjs/react.developme
nt.js:1208:15
1205: };
1206: exports.useContext = function (Context) {
1207: var dispatcher = resolveDispatcher();
1208: Context.$$typeof === REACT_CONSUMER_TYPE &&
1209: console.error(
1210 "Calling useContext(Context.Consumer) is not supported and will
: cause bugs. Did you mean to call useContext(Context) instead?"
1211: );
-process.env.NODE_ENV.expo
ts.useContext (/Users/spencertang/Workspace/gemini-cli/node_module
s/react/cjs/react.development.js:1208:15)
- useTerminalCapabilities (src/ui/hooks/useTerminalCapabilities.ts:15:19)
- useAlternateBuffer (src/ui/hooks/useAlternateBuffer.ts:15:24)
- ChoiceQuestionView (src/ui/components/AskUserDialog.tsx:484:29)
-Object.react-stack-bot
om-frame (/Users/spencertang/Workspace/gemini-cli/node_modules/r
eact-reconciler/cjs/react-reconciler.development.js:158
59:20)
-renderWithHo
ks (/Users/spencertang/Workspace/gemini-cli/node_modules/react-recon
ciler/cjs/react-reconciler.development.js:3221:22)
-updateFunctionCom
onent (/Users/spencertang/Workspace/gemini-cli/node_modules/react-
reconciler/cjs/react-reconciler.development.js:6475:19)
-beginWor
(/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconcile
r/cjs/react-reconciler.development.js:8009:18)
-runWithFiberIn
EV (/Users/spencertang/Workspace/gemini-cli/node_modules/react-rec
onciler/cjs/react-reconciler.development.js:1738:13)
-performUnitOfW
rk (/Users/spencertang/Workspace/gemini-cli/node_modules/react-rec
onciler/cjs/react-reconciler.development.js:12834:22)
"
`;
exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 1`] = `
"Select your preferred language:
@@ -22,6 +66,50 @@ Enter to submit · Esc to cancel
"
`;
exports[`AskUserDialog > Choice question placeholder > uses placeholder for "Other" option when provided 2`] = `
"
ERROR Cannot read properties of undefined (reading '$$typeof')
/Users/spencertang/Workspace/gemini-cli/node_modules/react/cjs/react.developme
nt.js:1208:15
1205: };
1206: exports.useContext = function (Context) {
1207: var dispatcher = resolveDispatcher();
1208: Context.$$typeof === REACT_CONSUMER_TYPE &&
1209: console.error(
1210 "Calling useContext(Context.Consumer) is not supported and will
: cause bugs. Did you mean to call useContext(Context) instead?"
1211: );
-process.env.NODE_ENV.expo
ts.useContext (/Users/spencertang/Workspace/gemini-cli/node_module
s/react/cjs/react.development.js:1208:15)
- useTerminalCapabilities (src/ui/hooks/useTerminalCapabilities.ts:15:19)
- useAlternateBuffer (src/ui/hooks/useAlternateBuffer.ts:15:24)
- ChoiceQuestionView (src/ui/components/AskUserDialog.tsx:484:29)
-Object.react-stack-bot
om-frame (/Users/spencertang/Workspace/gemini-cli/node_modules/r
eact-reconciler/cjs/react-reconciler.development.js:158
59:20)
-renderWithHo
ks (/Users/spencertang/Workspace/gemini-cli/node_modules/react-recon
ciler/cjs/react-reconciler.development.js:3221:22)
-updateFunctionCom
onent (/Users/spencertang/Workspace/gemini-cli/node_modules/react-
reconciler/cjs/react-reconciler.development.js:6475:19)
-beginWor
(/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconcile
r/cjs/react-reconciler.development.js:8009:18)
-runWithFiberIn
EV (/Users/spencertang/Workspace/gemini-cli/node_modules/react-rec
onciler/cjs/react-reconciler.development.js:1738:13)
-performUnitOfW
rk (/Users/spencertang/Workspace/gemini-cli/node_modules/react-rec
onciler/cjs/react-reconciler.development.js:12834:22)
"
`;
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: false) > shows scroll arrows correctly when useAlternateBuffer is false 1`] = `
"Choose an option
@@ -75,6 +163,48 @@ Enter to select · ↑/↓ to navigate · Esc to cancel
"
`;
exports[`AskUserDialog > Scroll Arrows (useAlternateBuffer: true) > shows scroll arrows correctly when useAlternateBuffer is true 2`] = `
"
ERROR Cannot read properties of undefined (reading '$$typeof')
/Users/spencertang/Workspace/gemini-cli/node_modules/react/cjs/react.development.js:1208:15
1205: };
1206: exports.useContext = function (Context) {
1207: var dispatcher = resolveDispatcher();
1208: Context.$$typeof === REACT_CONSUMER_TYPE &&
1209: console.error(
1210 "Calling useContext(Context.Consumer) is not supported and will cause bugs. Did you
: mean to call useContext(Context) instead?"
1211: );
-process.env.NODE_ENV.exports
useContext (/Users/spencertang/Workspace/gemini-cli/node_modules/react/cjs/react
.development.js:1208:15)
- useTerminalCapabilities (src/ui/hooks/useTerminalCapabilities.ts:15:19)
- useAlternateBuffer (src/ui/hooks/useAlternateBuffer.ts:15:24)
- ChoiceQuestionView (src/ui/components/AskUserDialog.tsx:484:29)
-Object.react-stack-bott
m-frame (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs
/react-reconciler.development.js:15859:20)
-renderWithHoo
s (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-rec
onciler.development.js:3221:22)
-updateFunctionComp
nent (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/reac
t-reconciler.development.js:6475:19)
-beginWor
(/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-reconcil
er.development.js:8009:18)
-runWithFiberIn
EV (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-re
conciler.development.js:1738:13)
-performUnitOfW
rk (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-re
conciler.development.js:12834:22)
"
`;
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
"What should we name this component?
@@ -201,3 +331,45 @@ README → (not answered)
Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel
"
`;
exports[`AskUserDialog > shows warning for unanswered questions on Review tab 2`] = `
"
ERROR Cannot read properties of undefined (reading '$$typeof')
/Users/spencertang/Workspace/gemini-cli/node_modules/react/cjs/react.development.js:1208:15
1205: };
1206: exports.useContext = function (Context) {
1207: var dispatcher = resolveDispatcher();
1208: Context.$$typeof === REACT_CONSUMER_TYPE &&
1209: console.error(
1210: "Calling useContext(Context.Consumer) is not supported and will cause bugs. Did you mean to call
useContext(Context) instead?"
1211: );
-process.env.NODE_ENV.exports.useCo
text (/Users/spencertang/Workspace/gemini-cli/node_modules/react/cjs/react.development.j
s:1208:15)
- useTerminalCapabilities (src/ui/hooks/useTerminalCapabilities.ts:15:19)
- useAlternateBuffer (src/ui/hooks/useAlternateBuffer.ts:15:24)
- ChoiceQuestionView (src/ui/components/AskUserDialog.tsx:484:29)
-Object.react-stack-botto
-frame (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.d
evelopment.js:15859:20)
-renderWithHook
(/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development
.js:3221:22)
-updateFunctionCompo
ent (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.develo
pment.js:6475:19)
-beginWork
(/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.development.js:8
009:18)
-runWithFiberInD
V (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developmen
t.js:1738:13)
-performUnitOfWo
k (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-reconciler.developmen
t.js:12834:22)
"
`;
@@ -6,6 +6,51 @@ Spinner Initializing...
"
`;
exports[`ConfigInitDisplay > handles empty clients map 2`] = `
"
ERROR Cannot read properties of undefined (reading 'isKittyProtocolEnabled')
src/ui/contexts/KeypressContext.tsx:797:36
794: process.stdin.setEncoding('utf8'); // Make data events emit strings
795:
796: let processor = nonKeyboardEventFilter(broadcast);
797: if (!terminalCapabilityManager.isKittyProtocolEnabled()) {
798: processor = bufferFastReturn(processor);
799: }
800: processor = bufferBackslashEnter(processor);
- (src/ui/contexts/KeypressContext.tsx:797:36)
-Object.react-stack-bott
m-frame (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs
/react-reconciler.development.js:15945:20)
-runWithFiberIn
EV (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-re
conciler.development.js:1738:13)
-commitHookEffectList
ount (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
act-reconciler.development.js:9516:29)
-commitHookPassiveMount
ffects (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/
react-reconciler.development.js:9639:11)
-commitPassiveMountOn
iber (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
act-reconciler.development.js:11364:13)
-recursivelyTraversePassiveM
untEffects (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler
/cjs/react-reconciler.development.js:11338:11)
-commitPassiveMountOn
iber (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
act-reconciler.development.js:11479:11)
-recursivelyTraversePassiveM
untEffects (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler
/cjs/react-reconciler.development.js:11338:11)
-commitPassiveMountOn
iber (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
act-reconciler.development.js:11357:11)
"
`;
exports[`ConfigInitDisplay > renders initial state 1`] = `
"
Spinner Initializing...
@@ -18,8 +63,98 @@ Spinner Connecting to MCP servers... (0/5) - Waiting for: s1, s2, s3, +2 more
"
`;
exports[`ConfigInitDisplay > truncates list of waiting servers if too many 2`] = `
"
ERROR Cannot read properties of undefined (reading 'isKittyProtocolEnabled')
src/ui/contexts/KeypressContext.tsx:797:36
794: process.stdin.setEncoding('utf8'); // Make data events emit strings
795:
796: let processor = nonKeyboardEventFilter(broadcast);
797: if (!terminalCapabilityManager.isKittyProtocolEnabled()) {
798: processor = bufferFastReturn(processor);
799: }
800: processor = bufferBackslashEnter(processor);
- (src/ui/contexts/KeypressContext.tsx:797:36)
-Object.react-stack-bott
m-frame (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs
/react-reconciler.development.js:15945:20)
-runWithFiberIn
EV (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-re
conciler.development.js:1738:13)
-commitHookEffectList
ount (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
act-reconciler.development.js:9516:29)
-commitHookPassiveMount
ffects (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/
react-reconciler.development.js:9639:11)
-commitPassiveMountOn
iber (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
act-reconciler.development.js:11364:13)
-recursivelyTraversePassiveM
untEffects (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler
/cjs/react-reconciler.development.js:11338:11)
-commitPassiveMountOn
iber (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
act-reconciler.development.js:11479:11)
-recursivelyTraversePassiveM
untEffects (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler
/cjs/react-reconciler.development.js:11338:11)
-commitPassiveMountOn
iber (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
act-reconciler.development.js:11357:11)
"
`;
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 1`] = `
"
Spinner Connecting to MCP servers... (1/2) - Waiting for: server2
"
`;
exports[`ConfigInitDisplay > updates message on McpClientUpdate event 2`] = `
"
ERROR Cannot read properties of undefined (reading 'isKittyProtocolEnabled')
src/ui/contexts/KeypressContext.tsx:797:36
794: process.stdin.setEncoding('utf8'); // Make data events emit strings
795:
796: let processor = nonKeyboardEventFilter(broadcast);
797: if (!terminalCapabilityManager.isKittyProtocolEnabled()) {
798: processor = bufferFastReturn(processor);
799: }
800: processor = bufferBackslashEnter(processor);
- (src/ui/contexts/KeypressContext.tsx:797:36)
-Object.react-stack-bott
m-frame (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs
/react-reconciler.development.js:15945:20)
-runWithFiberIn
EV (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/react-re
conciler.development.js:1738:13)
-commitHookEffectList
ount (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
act-reconciler.development.js:9516:29)
-commitHookPassiveMount
ffects (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/
react-reconciler.development.js:9639:11)
-commitPassiveMountOn
iber (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
act-reconciler.development.js:11364:13)
-recursivelyTraversePassiveM
untEffects (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler
/cjs/react-reconciler.development.js:11338:11)
-commitPassiveMountOn
iber (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
act-reconciler.development.js:11479:11)
-recursivelyTraversePassiveM
untEffects (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler
/cjs/react-reconciler.development.js:11338:11)
-commitPassiveMountOn
iber (/Users/spencertang/Workspace/gemini-cli/node_modules/react-reconciler/cjs/re
act-reconciler.development.js:11357:11)
"
`;
File diff suppressed because it is too large Load Diff
@@ -1,378 +0,0 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { TerminalCapabilityManager } from './terminalCapabilityManager.js';
import { EventEmitter } from 'node:events';
import {
enableKittyKeyboardProtocol,
enableModifyOtherKeys,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';
// Mock fs
vi.mock('node:fs', () => ({
writeSync: vi.fn(),
}));
// Mock core
vi.mock('@google/gemini-cli-core', () => ({
debugLogger: {
log: vi.fn(),
warn: vi.fn(),
},
enableKittyKeyboardProtocol: vi.fn(),
disableKittyKeyboardProtocol: vi.fn(),
enableModifyOtherKeys: vi.fn(),
disableModifyOtherKeys: vi.fn(),
enableBracketedPasteMode: vi.fn(),
disableBracketedPasteMode: vi.fn(),
}));
describe('TerminalCapabilityManager', () => {
let stdin: EventEmitter & {
isTTY?: boolean;
isRaw?: boolean;
setRawMode?: (mode: boolean) => void;
removeListener?: (
event: string,
listener: (...args: unknown[]) => void,
) => void;
};
let stdout: { isTTY?: boolean; fd?: number };
// Save original process properties
const originalStdin = process.stdin;
const originalStdout = process.stdout;
beforeEach(() => {
vi.resetAllMocks();
// Reset singleton
TerminalCapabilityManager.resetInstanceForTesting();
// Setup process mocks
stdin = new EventEmitter();
stdin.isTTY = true;
stdin.isRaw = false;
stdin.setRawMode = vi.fn();
stdin.removeListener = vi.fn();
stdout = { isTTY: true, fd: 1 };
// Use defineProperty to mock process.stdin/stdout
Object.defineProperty(process, 'stdin', {
value: stdin,
configurable: true,
});
Object.defineProperty(process, 'stdout', {
value: stdout,
configurable: true,
});
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
// Restore original process properties
Object.defineProperty(process, 'stdin', {
value: originalStdin,
configurable: true,
});
Object.defineProperty(process, 'stdout', {
value: originalStdout,
configurable: true,
});
});
it('should detect Kitty support when u response is received', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Simulate Kitty response: \x1b[?1u
stdin.emit('data', Buffer.from('\x1b[?1u'));
// Complete detection with DA1
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(manager.isKittyProtocolEnabled()).toBe(true);
});
it('should detect Background Color', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Simulate OSC 11 response
// \x1b]11;rgb:0000/ff00/0000\x1b\
// RGB: 0, 255, 0 -> #00ff00
stdin.emit('data', Buffer.from('\x1b]11;rgb:0000/ffff/0000\x1b\\'));
// Complete detection with DA1
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(manager.getTerminalBackgroundColor()).toBe('#00ff00');
});
it('should detect Terminal Name', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Simulate Terminal Name response
stdin.emit('data', Buffer.from('\x1bP>|WezTerm 20240203\x1b\\'));
// Complete detection with DA1
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(manager.getTerminalName()).toBe('WezTerm 20240203');
});
it('should complete early if sentinel (DA1) is found', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
stdin.emit('data', Buffer.from('\x1b[?1u'));
stdin.emit('data', Buffer.from('\x1b]11;rgb:0000/0000/0000\x1b\\'));
// Sentinel
stdin.emit('data', Buffer.from('\x1b[?62c'));
// Should resolve without waiting for timeout
await promise;
expect(manager.isKittyProtocolEnabled()).toBe(true);
expect(manager.getTerminalBackgroundColor()).toBe('#000000');
});
it('should timeout if no DA1 (c) is received', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Simulate only Kitty response
stdin.emit('data', Buffer.from('\x1b[?1u'));
// Advance to timeout
vi.advanceTimersByTime(1000);
await promise;
expect(manager.isKittyProtocolEnabled()).toBe(true);
});
it('should not detect Kitty if only DA1 (c) is received', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Simulate DA1 response only: \x1b[?62;c
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(manager.isKittyProtocolEnabled()).toBe(false);
});
it('should handle split chunks', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Split response: \x1b[? 1u
stdin.emit('data', Buffer.from('\x1b[?'));
stdin.emit('data', Buffer.from('1u'));
// Complete with DA1
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(manager.isKittyProtocolEnabled()).toBe(true);
});
describe('modifyOtherKeys detection', () => {
it('should detect modifyOtherKeys support (level 2)', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Simulate modifyOtherKeys level 2 response: \x1b[>4;2m
stdin.emit('data', Buffer.from('\x1b[>4;2m'));
// Complete detection with DA1
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(enableModifyOtherKeys).toHaveBeenCalled();
});
it('should not enable modifyOtherKeys for level 0', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Simulate modifyOtherKeys level 0 response: \x1b[>4;0m
stdin.emit('data', Buffer.from('\x1b[>4;0m'));
// Complete detection with DA1
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(enableModifyOtherKeys).not.toHaveBeenCalled();
});
it('should prefer Kitty over modifyOtherKeys', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Simulate both Kitty and modifyOtherKeys responses
stdin.emit('data', Buffer.from('\x1b[?1u'));
stdin.emit('data', Buffer.from('\x1b[>4;2m'));
// Complete detection with DA1
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(manager.isKittyProtocolEnabled()).toBe(true);
expect(enableKittyKeyboardProtocol).toHaveBeenCalled();
expect(enableModifyOtherKeys).not.toHaveBeenCalled();
});
it('should enable modifyOtherKeys when Kitty not supported', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Simulate only modifyOtherKeys response (no Kitty)
stdin.emit('data', Buffer.from('\x1b[>4;2m'));
// Complete detection with DA1
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(manager.isKittyProtocolEnabled()).toBe(false);
expect(enableModifyOtherKeys).toHaveBeenCalled();
});
it('should handle split modifyOtherKeys response chunks', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Split response: \x1b[>4;2m
stdin.emit('data', Buffer.from('\x1b[>4;'));
stdin.emit('data', Buffer.from('2m'));
// Complete detection with DA1
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(enableModifyOtherKeys).toHaveBeenCalled();
});
it('should detect modifyOtherKeys with other capabilities', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
stdin.emit('data', Buffer.from('\x1b]11;rgb:1a1a/1a1a/1a1a\x1b\\')); // background color
stdin.emit('data', Buffer.from('\x1bP>|tmux\x1b\\')); // Terminal name
stdin.emit('data', Buffer.from('\x1b[>4;2m')); // modifyOtherKeys
// Complete detection with DA1
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(manager.getTerminalBackgroundColor()).toBe('#1a1a1a');
expect(manager.getTerminalName()).toBe('tmux');
expect(enableModifyOtherKeys).toHaveBeenCalled();
});
it('should not enable modifyOtherKeys without explicit response', async () => {
const manager = TerminalCapabilityManager.getInstance();
const promise = manager.detectCapabilities();
// Simulate only DA1 response (no specific MOK or Kitty response)
stdin.emit('data', Buffer.from('\x1b[?62c'));
await promise;
expect(manager.isKittyProtocolEnabled()).toBe(false);
expect(enableModifyOtherKeys).not.toHaveBeenCalled();
});
it('should wrap queries in hidden/clear sequence', async () => {
const manager = TerminalCapabilityManager.getInstance();
void manager.detectCapabilities();
expect(fs.writeSync).toHaveBeenCalledWith(
expect.anything(),
// eslint-disable-next-line no-control-regex
expect.stringMatching(/^\x1b\[8m.*\x1b\[2K\r\x1b\[0m$/s),
);
});
});
describe('supportsOsc9Notifications', () => {
const manager = TerminalCapabilityManager.getInstance();
it.each([
{
name: 'WezTerm (terminal name)',
terminalName: 'WezTerm',
env: {},
expected: true,
},
{
name: 'iTerm.app (terminal name)',
terminalName: 'iTerm.app',
env: {},
expected: true,
},
{
name: 'ghostty (terminal name)',
terminalName: 'ghostty',
env: {},
expected: true,
},
{
name: 'kitty (terminal name)',
terminalName: 'kitty',
env: {},
expected: true,
},
{
name: 'some-other-term (terminal name)',
terminalName: 'some-other-term',
env: {},
expected: false,
},
{
name: 'iTerm.app (TERM_PROGRAM)',
terminalName: undefined,
env: { TERM_PROGRAM: 'iTerm.app' },
expected: true,
},
{
name: 'vscode (TERM_PROGRAM)',
terminalName: undefined,
env: { TERM_PROGRAM: 'vscode' },
expected: false,
},
{
name: 'xterm-kitty (TERM)',
terminalName: undefined,
env: { TERM: 'xterm-kitty' },
expected: true,
},
{
name: 'xterm-256color (TERM)',
terminalName: undefined,
env: { TERM: 'xterm-256color' },
expected: false,
},
{
name: 'Windows Terminal (WT_SESSION)',
terminalName: 'iTerm.app',
env: { WT_SESSION: 'some-guid' },
expected: false,
},
])(
'should return $expected for $name',
({ terminalName, env, expected }) => {
vi.spyOn(manager, 'getTerminalName').mockReturnValue(terminalName);
expect(manager.supportsOsc9Notifications(env)).toBe(expected);
},
);
});
});
@@ -84,6 +84,7 @@ describe('SubAgentInvocation', () => {
params: {},
getDescription: vi.fn(),
toolLocations: vi.fn(),
isSensitive: false,
};
MockSubagentToolWrapper.prototype.build = vi
-16
View File
@@ -501,9 +501,7 @@ export interface ConfigParameters {
experimentalZedIntegration?: boolean;
listSessions?: boolean;
deleteSession?: string;
autoAddPolicy?: boolean;
listExtensions?: boolean;
extensionLoader?: ExtensionLoader;
enabledExtensions?: string[];
enableExtensionReloading?: boolean;
@@ -659,7 +657,6 @@ export class Config implements McpContext {
private _activeModel: string;
private readonly maxSessionTurns: number;
private readonly autoAddPolicy: boolean;
private readonly listSessions: boolean;
private readonly deleteSession: string | undefined;
private readonly listExtensions: boolean;
@@ -895,7 +892,6 @@ export class Config implements McpContext {
params.experimentalZedIntegration ?? false;
this.listSessions = params.listSessions ?? false;
this.deleteSession = params.deleteSession;
this.autoAddPolicy = params.autoAddPolicy ?? false;
this.listExtensions = params.listExtensions ?? false;
this._extensionLoader =
params.extensionLoader ?? new SimpleExtensionLoader([]);
@@ -2219,18 +2215,6 @@ export class Config implements McpContext {
return this.bugCommand;
}
getAutoAddPolicy(): boolean {
if (this.disableYoloMode) {
return false;
}
return this.autoAddPolicy;
}
setAutoAddPolicy(value: boolean): void {
// @ts-expect-error - readonly property
this.autoAddPolicy = value;
}
getFileService(): FileDiscoveryService {
if (!this.fileDiscoveryService) {
this.fileDiscoveryService = new FileDiscoveryService(this.targetDir, {
-206
View File
@@ -1,206 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mocked,
} from 'vitest';
import * as fs from 'node:fs/promises';
import { createPolicyUpdater } from './config.js';
import {
MessageBusType,
type UpdatePolicy,
} from '../confirmation-bus/types.js';
import { coreEvents } from '../utils/events.js';
import type { PolicyEngine } from './policy-engine.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import type { Storage } from '../config/storage.js';
vi.mock('node:fs/promises');
vi.mock('../utils/events.js', () => ({
coreEvents: {
emitFeedback: vi.fn(),
},
}));
describe('Policy Auto-add Safeguards', () => {
let policyEngine: Mocked<PolicyEngine>;
let messageBus: Mocked<MessageBus>;
let storage: Mocked<Storage>;
let updateCallback: (msg: UpdatePolicy) => Promise<void>;
beforeEach(() => {
policyEngine = {
addRule: vi.fn(),
} as unknown as Mocked<PolicyEngine>;
messageBus = {
subscribe: vi.fn((type, cb) => {
if (type === MessageBusType.UPDATE_POLICY) {
updateCallback = cb;
}
}),
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
storage = {
getWorkspacePoliciesDir: vi.fn().mockReturnValue('/tmp/policies'),
getAutoSavedPolicyPath: vi
.fn()
.mockReturnValue('/tmp/policies/autosaved.toml'),
} as unknown as Mocked<Storage>;
const enoent = new Error('ENOENT');
(enoent as NodeJS.ErrnoException).code = 'ENOENT';
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.readFile).mockRejectedValue(enoent);
vi.mocked(fs.open).mockResolvedValue({
writeFile: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
} as unknown as fs.FileHandle);
vi.mocked(fs.rename).mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should skip persistence for wildcard toolName', async () => {
createPolicyUpdater(policyEngine, messageBus, storage);
expect(updateCallback).toBeDefined();
await updateCallback({
type: MessageBusType.UPDATE_POLICY,
toolName: '*',
persist: true,
});
expect(fs.open).not.toHaveBeenCalled();
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining('Policy for all tools was not auto-saved'),
);
});
it('should skip persistence for broad argsPattern (.*)', async () => {
createPolicyUpdater(policyEngine, messageBus, storage);
await updateCallback({
type: MessageBusType.UPDATE_POLICY,
toolName: 'test-tool',
argsPattern: '.*',
persist: true,
});
expect(fs.open).not.toHaveBeenCalled();
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining('was not auto-saved for safety reasons'),
);
});
it('should allow persistence for specific argsPattern', async () => {
createPolicyUpdater(policyEngine, messageBus, storage);
await updateCallback({
type: MessageBusType.UPDATE_POLICY,
toolName: 'test-tool',
argsPattern: '.*"file_path":"test.txt".*',
persist: true,
});
await vi.waitFor(() => {
expect(fs.open).toHaveBeenCalled();
});
expect(fs.rename).toHaveBeenCalledWith(
expect.stringContaining('autosaved.toml'),
'/tmp/policies/autosaved.toml',
);
});
it('should skip persistence for sensitive tool with no pattern', async () => {
createPolicyUpdater(policyEngine, messageBus, storage);
await updateCallback({
type: MessageBusType.UPDATE_POLICY,
toolName: 'sensitive-tool',
isSensitive: true,
persist: true,
});
await vi.waitFor(() => {
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining(
'Broad approval for "sensitive-tool" was not auto-saved',
),
);
});
expect(fs.open).not.toHaveBeenCalled();
});
it('should skip persistence for MCP tool with no pattern', async () => {
createPolicyUpdater(policyEngine, messageBus, storage);
const mcpToolName = 'mcp-server__sensitive-tool';
await updateCallback({
type: MessageBusType.UPDATE_POLICY,
toolName: mcpToolName,
mcpName: 'mcp-server',
persist: true,
});
await vi.waitFor(() => {
expect(coreEvents.emitFeedback).toHaveBeenCalledWith(
'warning',
expect.stringContaining(
`Broad approval for "${mcpToolName}" was not auto-saved`,
),
);
});
expect(fs.open).not.toHaveBeenCalled();
});
it('should de-duplicate identical rules when auto-saving', async () => {
createPolicyUpdater(policyEngine, messageBus, storage);
// First call: file doesn't exist (ENOENT already mocked in beforeEach)
await updateCallback({
type: MessageBusType.UPDATE_POLICY,
toolName: 'read_file',
argsPattern: '.*"file_path":"test.txt".*',
persist: true,
isSensitive: true,
});
await vi.waitFor(() => {
expect(fs.open).toHaveBeenCalledTimes(1);
});
// Mock file existing with the rule for the second call
vi.mocked(fs.readFile).mockResolvedValue(
'[[rule]]\ntoolName = "read_file"\ndecision = "allow"\npriority = 100\nargsPattern = \'.*"file_path":"test.txt".*\'\n',
);
// Second call: should skip persistence because it's a duplicate
await updateCallback({
type: MessageBusType.UPDATE_POLICY,
toolName: 'read_file',
argsPattern: '.*"file_path":"test.txt".*',
persist: true,
isSensitive: true,
});
// Still only 1 call to fs.open
expect(fs.open).toHaveBeenCalledTimes(1);
});
});
-64
View File
@@ -514,40 +514,6 @@ export function createPolicyUpdater(
}
if (message.persist) {
// Validation safeguards for auto-adding to persistent policy
if (!toolName || toolName === '*') {
coreEvents.emitFeedback(
'warning',
'Policy for all tools was not auto-saved for safety reasons. You can add it manually to your policy file if desired.',
);
return;
}
const broadPatternRegex = /^\s*(\^)?\.\*(\$)?\s*$/;
if (
message.argsPattern &&
broadPatternRegex.test(message.argsPattern)
) {
coreEvents.emitFeedback(
'warning',
`Policy for "${toolName}" with all arguments was not auto-saved for safety reasons. You can add it manually to your policy file if desired.`,
);
return;
}
const isMcpTool = !!message.mcpName;
if (
(message.isSensitive || isMcpTool) &&
!message.argsPattern &&
!message.commandPrefix
) {
coreEvents.emitFeedback(
'warning',
`Broad approval for "${toolName}" was not auto-saved for safety reasons. Approvals for sensitive tools must be specific to be auto-saved.`,
);
return;
}
persistenceQueue = persistenceQueue.then(async () => {
try {
const policyFile = storage.getAutoSavedPolicyPath();
@@ -604,36 +570,6 @@ export function createPolicyUpdater(
newRule.argsPattern = message.argsPattern;
}
// De-duplicate: check if an identical rule already exists
const isDuplicate = existingData.rule.some((r) => {
const matchesTool = r.toolName === newRule.toolName;
const matchesMcp = r.mcpName === newRule.mcpName;
const matchesPattern = r.argsPattern === newRule.argsPattern;
const rPrefix = r.commandPrefix;
const newPrefix = newRule.commandPrefix;
let matchesPrefix = false;
if (Array.isArray(rPrefix) && Array.isArray(newPrefix)) {
matchesPrefix =
rPrefix.length === newPrefix.length &&
rPrefix.every((v, i) => v === newPrefix[i]);
} else {
matchesPrefix = rPrefix === newPrefix;
}
return (
matchesTool && matchesMcp && matchesPattern && matchesPrefix
);
});
if (isDuplicate) {
debugLogger.debug(
`Policy rule for ${toolName} already exists in persistent policy, skipping.`,
);
return;
}
// Add to rules
existingData.rule.push(newRule);
@@ -179,7 +179,6 @@ describe('createPolicyUpdater', () => {
toolName,
persist: true,
mcpName,
argsPattern: '{"issue": ".*"}',
});
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -190,7 +189,6 @@ describe('createPolicyUpdater', () => {
const writtenContent = writeCall[0] as string;
expect(writtenContent).toContain(`mcpName = "${mcpName}"`);
expect(writtenContent).toContain(`toolName = "${simpleToolName}"`);
expect(writtenContent).toContain(`argsPattern = '{"issue": ".*"}'`);
expect(writtenContent).toContain('priority = 200');
});
@@ -220,7 +218,6 @@ describe('createPolicyUpdater', () => {
toolName,
persist: true,
mcpName,
argsPattern: '{"query": ".*"}',
});
await new Promise((resolve) => setTimeout(resolve, 0));
@@ -243,7 +240,5 @@ describe('createPolicyUpdater', () => {
} catch {
expect(writtenContent).toContain(`toolName = 'search"tool"'`);
}
expect(writtenContent).toContain(`argsPattern = '{"query": ".*"}'`);
});
});
-14
View File
@@ -11,20 +11,6 @@ export function escapeRegex(text: string): string {
return text.replace(/[-[\]{}()*+?.,\\^$|#\s"]/g, '\\$&');
}
/**
* Escapes a string for use in a regular expression that matches a JSON-stringified value.
*
* This is necessary because some characters (like backslashes and quotes) are
* escaped twice in the final JSON string representation used for policy matching.
*/
export function escapeJsonRegex(text: string): string {
// 1. Get the JSON-escaped version of the string (e.g. C:\foo -> C:\\foo)
// 2. Remove the surrounding quotes
const jsonEscaped = JSON.stringify(text).slice(1, -1);
// 3. Regex-escape the result (e.g. C:\\foo -> C:\\\\foo)
return escapeRegex(jsonEscaped);
}
/**
* Basic validation for regular expressions to prevent common ReDoS patterns.
* This is a heuristic check and not a substitute for a full ReDoS scanner.
@@ -1,241 +0,0 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach, type Mocked } from 'vitest';
import { updatePolicy } from './policy.js';
import type { Config } from '../config/config.js';
import type { MessageBus } from '../confirmation-bus/message-bus.js';
import { MessageBusType } from '../confirmation-bus/types.js';
import {
ToolConfirmationOutcome,
type AnyDeclarativeTool,
type ToolEditConfirmationDetails,
} from '../tools/tools.js';
import {
READ_FILE_TOOL_NAME,
LS_TOOL_NAME,
WRITE_FILE_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
READ_MANY_FILES_TOOL_NAME,
WEB_FETCH_TOOL_NAME,
WEB_SEARCH_TOOL_NAME,
WRITE_TODOS_TOOL_NAME,
} from '../tools/tool-names.js';
describe('Scheduler Auto-add Policy Logic', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('should set persist: true for ProceedAlways if autoAddPolicy is enabled', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(true),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: 'test-tool',
isSensitive: false,
} as AnyDeclarativeTool;
await updatePolicy(tool, ToolConfirmationOutcome.ProceedAlways, undefined, {
config: mockConfig,
messageBus: mockMessageBus,
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
toolName: 'test-tool',
persist: true,
}),
);
});
it('should set persist: false for ProceedAlways if autoAddPolicy is disabled', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: 'test-tool',
isSensitive: false,
} as AnyDeclarativeTool;
await updatePolicy(tool, ToolConfirmationOutcome.ProceedAlways, undefined, {
config: mockConfig,
messageBus: mockMessageBus,
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
toolName: 'test-tool',
persist: false,
}),
);
});
it('should generate specific argsPattern for edit tools', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(true),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: WRITE_FILE_TOOL_NAME,
isSensitive: true,
} as AnyDeclarativeTool;
const details: ToolEditConfirmationDetails = {
type: 'edit',
title: 'Confirm Write',
fileName: 'test.txt',
filePath: 'test.txt',
fileDiff: '',
originalContent: '',
newContent: '',
onConfirm: vi.fn(),
};
await updatePolicy(tool, ToolConfirmationOutcome.ProceedAlways, details, {
config: mockConfig,
messageBus: mockMessageBus,
});
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
argsPattern: expect.stringMatching(/test.*txt/),
isSensitive: true,
}),
);
});
it.each([
{
name: 'read_file',
tool: {
name: READ_FILE_TOOL_NAME,
params: { file_path: 'read.me' },
},
pattern: /read\\.me/,
},
{
name: 'list_directory',
tool: {
name: LS_TOOL_NAME,
params: { dir_path: './src' },
},
pattern: /\.\/src/,
},
{
name: 'glob',
tool: {
name: GLOB_TOOL_NAME,
params: { dir_path: './packages' },
},
pattern: /\.\/packages/,
},
{
name: 'grep_search',
tool: {
name: GREP_TOOL_NAME,
params: { dir_path: './src' },
},
pattern: /\.\/src/,
},
{
name: 'read_many_files',
tool: {
name: READ_MANY_FILES_TOOL_NAME,
params: { include: ['src/**/*.ts', 'test/'] },
},
pattern: /include.*src.*ts.*test/,
},
{
name: 'web_fetch',
tool: {
name: WEB_FETCH_TOOL_NAME,
params: { prompt: 'Summarize https://example.com/page' },
},
pattern: /example\\.com/,
},
{
name: 'web_fetch (direct url)',
tool: {
name: WEB_FETCH_TOOL_NAME,
params: { url: 'https://google.com' },
},
pattern: /"url":"https:\/\/google\\.com"/,
},
{
name: 'web_search',
tool: {
name: WEB_SEARCH_TOOL_NAME,
params: { query: 'how to bake bread' },
},
pattern: /how\\ to\\ bake\\ bread/,
},
{
name: 'write_todos',
tool: {
name: WRITE_TODOS_TOOL_NAME,
params: { todos: [{ description: 'fix the bug', status: 'pending' }] },
},
pattern: /fix\\ the\\ bug/,
},
{
name: 'read_file (Windows path)',
tool: {
name: READ_FILE_TOOL_NAME,
params: { file_path: 'C:\\foo\\bar.txt' },
},
pattern: /C:\\\\\\\\foo\\\\\\\\bar\\.txt/,
},
])(
'should generate specific argsPattern for $name',
async ({ tool, pattern }) => {
const toolWithSensitive = {
...tool,
isSensitive: true,
} as unknown as AnyDeclarativeTool;
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(true),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
await updatePolicy(
toolWithSensitive,
ToolConfirmationOutcome.ProceedAlways,
undefined,
{
config: mockConfig,
messageBus: mockMessageBus,
},
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
toolName: tool.name,
argsPattern: expect.stringMatching(pattern),
isSensitive: true,
}),
);
},
);
});
+12 -136
View File
@@ -150,17 +150,13 @@ describe('policy.ts', () => {
describe('updatePolicy', () => {
it('should set AUTO_EDIT mode for auto-edit transition tools', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: 'replace',
isSensitive: false,
} as AnyDeclarativeTool; // 'replace' is in EDIT_TOOL_NAMES
const tool = { name: 'replace' } as AnyDeclarativeTool; // 'replace' is in EDIT_TOOL_NAMES
await updatePolicy(
tool,
@@ -172,27 +168,17 @@ describe('policy.ts', () => {
expect(mockConfig.setApprovalMode).toHaveBeenCalledWith(
ApprovalMode.AUTO_EDIT,
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
toolName: 'replace',
persist: false,
}),
);
expect(mockMessageBus.publish).not.toHaveBeenCalled();
});
it('should handle standard policy updates (persist=false)', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: 'test-tool',
isSensitive: false,
} as AnyDeclarativeTool;
const tool = { name: 'test-tool' } as AnyDeclarativeTool;
await updatePolicy(
tool,
@@ -212,16 +198,12 @@ describe('policy.ts', () => {
it('should handle standard policy updates with persistence', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: 'test-tool',
isSensitive: false,
} as AnyDeclarativeTool;
const tool = { name: 'test-tool' } as AnyDeclarativeTool;
await updatePolicy(
tool,
@@ -241,16 +223,12 @@ describe('policy.ts', () => {
it('should handle shell command prefixes', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: 'run_shell_command',
isSensitive: true,
} as AnyDeclarativeTool;
const tool = { name: 'run_shell_command' } as AnyDeclarativeTool;
const details: ToolExecuteConfirmationDetails = {
type: 'exec',
command: 'ls -la',
@@ -276,16 +254,12 @@ describe('policy.ts', () => {
it('should handle MCP policy updates (server scope)', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: 'mcp-tool',
isSensitive: false,
} as AnyDeclarativeTool;
const tool = { name: 'mcp-tool' } as AnyDeclarativeTool;
const details: ToolMcpConfirmationDetails = {
type: 'mcp',
serverName: 'my-server',
@@ -314,16 +288,12 @@ describe('policy.ts', () => {
it('should NOT publish update for ProceedOnce', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: 'test-tool',
isSensitive: false,
} as AnyDeclarativeTool;
const tool = { name: 'test-tool' } as AnyDeclarativeTool;
await updatePolicy(tool, ToolConfirmationOutcome.ProceedOnce, undefined, {
config: mockConfig,
@@ -336,16 +306,12 @@ describe('policy.ts', () => {
it('should NOT publish update for Cancel', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: 'test-tool',
isSensitive: false,
} as AnyDeclarativeTool;
const tool = { name: 'test-tool' } as AnyDeclarativeTool;
await updatePolicy(tool, ToolConfirmationOutcome.Cancel, undefined, {
config: mockConfig,
@@ -357,16 +323,12 @@ describe('policy.ts', () => {
it('should NOT publish update for ModifyWithEditor', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: 'test-tool',
isSensitive: false,
} as AnyDeclarativeTool;
const tool = { name: 'test-tool' } as AnyDeclarativeTool;
await updatePolicy(
tool,
@@ -380,16 +342,12 @@ describe('policy.ts', () => {
it('should handle MCP ProceedAlwaysTool (specific tool name)', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: 'mcp-tool',
isSensitive: false,
} as AnyDeclarativeTool;
const tool = { name: 'mcp-tool' } as AnyDeclarativeTool;
const details: ToolMcpConfirmationDetails = {
type: 'mcp',
serverName: 'my-server',
@@ -418,16 +376,12 @@ describe('policy.ts', () => {
it('should handle MCP ProceedAlways (persist: false)', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: 'mcp-tool',
isSensitive: false,
} as AnyDeclarativeTool;
const tool = { name: 'mcp-tool' } as AnyDeclarativeTool;
const details: ToolMcpConfirmationDetails = {
type: 'mcp',
serverName: 'my-server',
@@ -454,16 +408,12 @@ describe('policy.ts', () => {
it('should handle MCP ProceedAlwaysAndSave (persist: true)', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(false),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: 'mcp-tool',
isSensitive: false,
} as AnyDeclarativeTool;
const tool = { name: 'mcp-tool' } as AnyDeclarativeTool;
const details: ToolMcpConfirmationDetails = {
type: 'mcp',
serverName: 'my-server',
@@ -486,80 +436,6 @@ describe('policy.ts', () => {
toolName: 'mcp-tool',
mcpName: 'my-server',
persist: true,
argsPattern: '\\{.*\\}', // Fix verified: specific pattern provided for auto-add
}),
);
});
it('should NOT provide argsPattern for server-wide MCP approvals', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(true),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: 'mcp-tool',
isSensitive: false,
} as AnyDeclarativeTool;
const details: ToolMcpConfirmationDetails = {
type: 'mcp',
serverName: 'my-server',
toolName: 'mcp-tool',
toolDisplayName: 'My Tool',
title: 'MCP',
onConfirm: vi.fn(),
};
await updatePolicy(
tool,
ToolConfirmationOutcome.ProceedAlwaysServer,
details,
{ config: mockConfig, messageBus: mockMessageBus },
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
toolName: 'my-server__*',
mcpName: 'my-server',
persist: false, // Server-wide approvals currently do not auto-persist for safety
argsPattern: undefined, // Server-wide approvals are intentionally not specific
}),
);
});
it('should handle specificity for read_many_files', async () => {
const mockConfig = {
getAutoAddPolicy: vi.fn().mockReturnValue(true),
setApprovalMode: vi.fn(),
} as unknown as Mocked<Config>;
const mockMessageBus = {
publish: vi.fn(),
} as unknown as Mocked<MessageBus>;
const tool = {
name: 'read_many_files',
isSensitive: true,
params: { include: ['file1.ts', 'file2.ts'] },
} as unknown as AnyDeclarativeTool;
await updatePolicy(
tool,
ToolConfirmationOutcome.ProceedAlways,
undefined,
{
config: mockConfig,
messageBus: mockMessageBus,
},
);
expect(mockMessageBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageBusType.UPDATE_POLICY,
toolName: 'read_many_files',
persist: true,
argsPattern: expect.stringContaining('file1\\.ts'),
}),
);
});
+6 -165
View File
@@ -4,12 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { escapeJsonRegex } from '../policy/utils.js';
import { ToolErrorType } from '../tools/tool-error.js';
import {
MCP_QUALIFIED_NAME_SEPARATOR,
DiscoveredMCPTool,
} from '../tools/mcp-tool.js';
import {
ApprovalMode,
PolicyDecision,
@@ -27,37 +22,10 @@ import {
type AnyDeclarativeTool,
type PolicyUpdateOptions,
} from '../tools/tools.js';
import {
EDIT_TOOL_NAMES,
READ_FILE_TOOL_NAME,
LS_TOOL_NAME,
GLOB_TOOL_NAME,
GREP_TOOL_NAME,
READ_MANY_FILES_TOOL_NAME,
WEB_FETCH_TOOL_NAME,
WEB_SEARCH_TOOL_NAME,
WRITE_TODOS_TOOL_NAME,
GET_INTERNAL_DOCS_TOOL_NAME,
} from '../tools/tool-names.js';
import { DiscoveredMCPTool } from '../tools/mcp-tool.js';
import { EDIT_TOOL_NAMES } from '../tools/tool-names.js';
import type { ValidatingToolCall } from './types.js';
interface ToolWithParams {
params: Record<string, unknown>;
}
function hasParams(
tool: AnyDeclarativeTool,
): tool is AnyDeclarativeTool & ToolWithParams {
const t = tool as unknown;
return (
typeof t === 'object' &&
t !== null &&
'params' in t &&
typeof (t as { params: unknown }).params === 'object' &&
(t as { params: unknown }).params !== null
);
}
/**
* Helper to format the policy denial error.
*/
@@ -131,6 +99,7 @@ export async function updatePolicy(
// Mode Transitions (AUTO_EDIT)
if (isAutoEditTransition(tool, outcome)) {
deps.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
return;
}
// Specialized Tools (MCP)
@@ -139,7 +108,6 @@ export async function updatePolicy(
tool,
outcome,
confirmationDetails,
deps.config,
deps.messageBus,
);
return;
@@ -150,7 +118,6 @@ export async function updatePolicy(
tool,
outcome,
confirmationDetails,
deps.config,
deps.messageBus,
);
}
@@ -172,99 +139,6 @@ function isAutoEditTransition(
);
}
type SpecificityGenerator = (
tool: AnyDeclarativeTool,
confirmationDetails?: SerializableConfirmationDetails,
) => string | undefined;
const specificityGenerators: Record<string, SpecificityGenerator> = {
[READ_FILE_TOOL_NAME]: (tool) => {
if (!hasParams(tool)) return undefined;
const filePath = tool.params['file_path'];
if (typeof filePath !== 'string') return undefined;
const escapedPath = escapeJsonRegex(filePath);
return `.*"file_path":"${escapedPath}".*`;
},
[LS_TOOL_NAME]: (tool) => {
if (!hasParams(tool)) return undefined;
const dirPath = tool.params['dir_path'];
if (typeof dirPath !== 'string') return undefined;
const escapedPath = escapeJsonRegex(dirPath);
return `.*"dir_path":"${escapedPath}".*`;
},
[GLOB_TOOL_NAME]: (tool) => specificityGenerators[LS_TOOL_NAME](tool),
[GREP_TOOL_NAME]: (tool) => specificityGenerators[LS_TOOL_NAME](tool),
[READ_MANY_FILES_TOOL_NAME]: (tool) => {
if (!hasParams(tool)) return undefined;
const include = tool.params['include'];
if (!Array.isArray(include) || include.length === 0) return undefined;
const lookaheads = include
.map((p) => escapeJsonRegex(String(p)))
.map((p) => `(?=.*"${p}")`)
.join('');
const pattern = `.*"include":\\[${lookaheads}.*\\].*`;
// Limit regex length for safety
if (pattern.length > 2048) {
return '.*"include":\\[.*\\].*';
}
return pattern;
},
[WEB_FETCH_TOOL_NAME]: (tool) => {
if (!hasParams(tool)) return undefined;
const url = tool.params['url'];
if (typeof url === 'string') {
const escaped = escapeJsonRegex(url);
return `.*"url":"${escaped}".*`;
}
const prompt = tool.params['prompt'];
if (typeof prompt !== 'string') return undefined;
const urlMatches = prompt.matchAll(/https?:\/\/[^\s"']+/g);
const urls = Array.from(urlMatches)
.map((m) => m[0])
.slice(0, 3);
if (urls.length === 0) return undefined;
const lookaheads = urls
.map((u) => escapeJsonRegex(u))
.map((u) => `(?=.*${u})`)
.join('');
return `.*${lookaheads}.*`;
},
[WEB_SEARCH_TOOL_NAME]: (tool) => {
if (!hasParams(tool)) return undefined;
const query = tool.params['query'];
if (typeof query === 'string') {
const escaped = escapeJsonRegex(query);
return `.*"query":"${escaped}".*`;
}
// Fallback to a pattern that matches any arguments
// but isn't just ".*" to satisfy the auto-add safeguard.
return '\\{.*\\}';
},
[WRITE_TODOS_TOOL_NAME]: (tool) => {
if (!hasParams(tool)) return undefined;
const todos = tool.params['todos'];
if (!Array.isArray(todos)) return undefined;
const escaped = todos
.filter(
(v): v is { description: string } => typeof v?.description === 'string',
)
.map((v) => escapeJsonRegex(v.description))
.join('|');
if (!escaped) return undefined;
return `.*"todos":\\[.*(?:${escaped}).*\\].*`;
},
[GET_INTERNAL_DOCS_TOOL_NAME]: (tool) => {
if (!hasParams(tool)) return undefined;
const filePath = tool.params['file_path'];
if (typeof filePath !== 'string') return undefined;
const escaped = escapeJsonRegex(filePath);
return `.*"file_path":"${escaped}".*`;
},
};
/**
* Handles policy updates for standard tools (Shell, Info, etc.), including
* session-level and persistent approvals.
@@ -273,7 +147,6 @@ async function handleStandardPolicyUpdate(
tool: AnyDeclarativeTool,
outcome: ToolConfirmationOutcome,
confirmationDetails: SerializableConfirmationDetails | undefined,
config: Config,
messageBus: MessageBus,
): Promise<void> {
if (
@@ -286,27 +159,10 @@ async function handleStandardPolicyUpdate(
options.commandPrefix = confirmationDetails.rootCommands;
}
if (confirmationDetails?.type === 'edit') {
// Generate a specific argsPattern for file edits to prevent broad approvals
const escapedPath = escapeJsonRegex(confirmationDetails.filePath);
options.argsPattern = `.*"file_path":"${escapedPath}".*`;
} else {
const generator = specificityGenerators[tool.name];
if (generator) {
options.argsPattern = generator(tool, confirmationDetails);
}
}
const persist =
outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave ||
(outcome === ToolConfirmationOutcome.ProceedAlways &&
config.getAutoAddPolicy());
await messageBus.publish({
type: MessageBusType.UPDATE_POLICY,
toolName: tool.name,
persist,
isSensitive: tool.isSensitive,
persist: outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave,
...options,
});
}
@@ -323,7 +179,6 @@ async function handleMcpPolicyUpdate(
SerializableConfirmationDetails,
{ type: 'mcp' }
>,
config: Config,
messageBus: MessageBus,
): Promise<void> {
const isMcpAlways =
@@ -337,31 +192,17 @@ async function handleMcpPolicyUpdate(
}
let toolName = tool.name;
const persist =
outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave ||
(outcome === ToolConfirmationOutcome.ProceedAlways &&
config.getAutoAddPolicy());
const persist = outcome === ToolConfirmationOutcome.ProceedAlwaysAndSave;
// If "Always allow all tools from this server", use the wildcard pattern
if (outcome === ToolConfirmationOutcome.ProceedAlwaysServer) {
toolName = `${confirmationDetails.serverName}${MCP_QUALIFIED_NAME_SEPARATOR}*`;
toolName = `${confirmationDetails.serverName}__*`;
}
// MCP tools are treated as sensitive, so we MUST provide a specific argsPattern
// or commandPrefix to satisfy the auto-add safeguard in createPolicyUpdater.
// For single-tool approvals, we default to a pattern that matches the JSON structure
// of the arguments string (e.g. \{.*\}).
const argsPattern =
outcome !== ToolConfirmationOutcome.ProceedAlwaysServer
? '\\{.*\\}'
: undefined;
await messageBus.publish({
type: MessageBusType.UPDATE_POLICY,
toolName,
mcpName: confirmationDetails.serverName,
persist,
isSensitive: tool.isSensitive,
argsPattern,
});
}
+9 -3
View File
@@ -29,6 +29,7 @@ import { PolicyDecision } from '../policy/types.js';
import {
ToolConfirmationOutcome,
type AnyDeclarativeTool,
Kind,
} from '../tools/tools.js';
import { getToolSuggestion } from '../utils/tool-utils.js';
import { runInDevTraceSpan } from '../telemetry/trace.js';
@@ -427,11 +428,11 @@ export class Scheduler {
return true;
}
// If the first tool is read-only, batch all contiguous read-only tools.
if (next.tool?.isReadOnly) {
// If the first tool is parallelizable, batch all contiguous parallelizable tools.
if (this._isParallelizable(next.tool)) {
while (this.state.queueLength > 0) {
const peeked = this.state.peekQueue();
if (peeked && peeked.tool?.isReadOnly) {
if (peeked && this._isParallelizable(peeked.tool)) {
this.state.dequeue();
} else {
break;
@@ -516,6 +517,11 @@ export class Scheduler {
return false;
}
private _isParallelizable(tool?: AnyDeclarativeTool): boolean {
if (!tool) return false;
return tool.isReadOnly || tool.kind === Kind.Agent;
}
private async _processValidatingCall(
active: ValidatingToolCall,
signal: AbortSignal,
@@ -70,6 +70,7 @@ import { ApprovalMode, PolicyDecision } from '../policy/types.js';
import {
type AnyDeclarativeTool,
type AnyToolInvocation,
Kind,
} from '../tools/tools.js';
import type {
ToolCallRequestInfo,
@@ -124,18 +125,51 @@ describe('Scheduler Parallel Execution', () => {
schedulerId: ROOT_SCHEDULER_ID,
};
const agentReq1: ToolCallRequestInfo = {
callId: 'agent-1',
name: 'agent-tool-1',
args: { query: 'do thing 1' },
isClientInitiated: false,
prompt_id: 'p1',
schedulerId: ROOT_SCHEDULER_ID,
};
const agentReq2: ToolCallRequestInfo = {
callId: 'agent-2',
name: 'agent-tool-2',
args: { query: 'do thing 2' },
isClientInitiated: false,
prompt_id: 'p1',
schedulerId: ROOT_SCHEDULER_ID,
};
const readTool1 = {
name: 'read-tool-1',
kind: Kind.Read,
isReadOnly: true,
build: vi.fn(),
} as unknown as AnyDeclarativeTool;
const readTool2 = {
name: 'read-tool-2',
kind: Kind.Read,
isReadOnly: true,
build: vi.fn(),
} as unknown as AnyDeclarativeTool;
const writeTool = {
name: 'write-tool',
kind: Kind.Execute,
isReadOnly: false,
build: vi.fn(),
} as unknown as AnyDeclarativeTool;
const agentTool1 = {
name: 'agent-tool-1',
kind: Kind.Agent,
isReadOnly: false,
build: vi.fn(),
} as unknown as AnyDeclarativeTool;
const agentTool2 = {
name: 'agent-tool-2',
kind: Kind.Agent,
isReadOnly: false,
build: vi.fn(),
} as unknown as AnyDeclarativeTool;
@@ -160,11 +194,19 @@ describe('Scheduler Parallel Execution', () => {
if (name === 'read-tool-1') return readTool1;
if (name === 'read-tool-2') return readTool2;
if (name === 'write-tool') return writeTool;
if (name === 'agent-tool-1') return agentTool1;
if (name === 'agent-tool-2') return agentTool2;
return undefined;
}),
getAllToolNames: vi
.fn()
.mockReturnValue(['read-tool-1', 'read-tool-2', 'write-tool']),
.mockReturnValue([
'read-tool-1',
'read-tool-2',
'write-tool',
'agent-tool-1',
'agent-tool-2',
]),
} as unknown as Mocked<ToolRegistry>;
mockConfig = {
@@ -279,6 +321,12 @@ describe('Scheduler Parallel Execution', () => {
vi.mocked(writeTool.build).mockReturnValue(
mockInvocation as unknown as AnyToolInvocation,
);
vi.mocked(agentTool1.build).mockReturnValue(
mockInvocation as unknown as AnyToolInvocation,
);
vi.mocked(agentTool2.build).mockReturnValue(
mockInvocation as unknown as AnyToolInvocation,
);
});
afterEach(() => {
@@ -418,4 +466,41 @@ describe('Scheduler Parallel Execution', () => {
expect(executionLog.indexOf('start-call-4')).toBeGreaterThan(end3);
expect(executionLog.indexOf('start-call-5')).toBeGreaterThan(end3);
});
it('should execute [Agent, Agent, Sequential, Parallelizable] in three waves', async () => {
const executionLog: string[] = [];
mockExecutor.execute.mockImplementation(async ({ call }) => {
const id = call.request.callId;
executionLog.push(`start-${id}`);
await new Promise<void>((resolve) => setTimeout(resolve, 10));
executionLog.push(`end-${id}`);
return {
status: 'success',
response: { callId: id, responseParts: [] },
} as unknown as SuccessfulToolCall;
});
// Schedule: agentReq1 (Parallel), agentReq2 (Parallel), req3 (Sequential/Write), req1 (Parallel/Read)
await scheduler.schedule([agentReq1, agentReq2, req3, req1], signal);
// Wave 1: agent-1, agent-2 (parallel)
expect(executionLog.slice(0, 2)).toContain('start-agent-1');
expect(executionLog.slice(0, 2)).toContain('start-agent-2');
// Both agents must end before anything else starts
const endAgent1 = executionLog.indexOf('end-agent-1');
const endAgent2 = executionLog.indexOf('end-agent-2');
const wave1End = Math.max(endAgent1, endAgent2);
// Wave 2: call-3 (sequential/write)
const start3 = executionLog.indexOf('start-call-3');
const end3 = executionLog.indexOf('end-call-3');
expect(start3).toBeGreaterThan(wave1End);
expect(end3).toBeGreaterThan(start3);
// Wave 3: call-1 (parallelizable/read)
const start1 = executionLog.indexOf('start-call-1');
expect(start1).toBeGreaterThan(end3);
});
});
+1
View File
@@ -293,6 +293,7 @@ describe('AskUserTool', () => {
getDescription: vi.fn().mockReturnValue(''),
toolLocations: vi.fn().mockReturnValue([]),
shouldConfirmExecute: vi.fn().mockResolvedValue(false),
isSensitive: false,
};
const buildSpy = vi.spyOn(tool, 'build').mockReturnValue(mockInvocation);
+15
View File
@@ -202,6 +202,21 @@ describe('EditTool', () => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('should be marked as sensitive and pass the flag to its invocations', () => {
// Check the tool definition itself
expect(tool.isSensitive).toBe(true);
// Build an invocation and check the instance
const params: EditToolParams = {
file_path: path.join(rootDir, 'test.txt'),
instruction: 'An instruction',
old_string: 'old',
new_string: 'new',
};
const invocation = tool.build(params);
expect(invocation.isSensitive).toBe(true);
});
describe('applyReplacement', () => {
it('should return newString if isNewFile is true', () => {
expect(applyReplacement(null, 'old', 'new', true)).toBe('new');
+74 -102
View File
@@ -11,6 +11,7 @@ import type { ToolInvocation, ToolLocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolErrorType } from './tool-error.js';
import type { PartUnion } from '@google/genai';
import {
processSingleFileContent,
getSpecificMimeType,
@@ -44,29 +45,20 @@ export interface ReadFileToolParams {
*/
end_line?: number;
}
class ReadFileToolInvocation extends BaseToolInvocation<
ReadFileToolParams,
ToolResult
> {
private readonly resolvedPath: string;
constructor(
private readonly config: Config,
private config: Config,
params: ReadFileToolParams,
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
) {
super(
params,
messageBus,
_toolName,
_toolDisplayName,
undefined,
undefined,
isSensitive,
);
super(params, messageBus, _toolName, _toolDisplayName);
this.resolvedPath = path.resolve(
this.config.getTargetDir(),
this.params.file_path,
@@ -105,80 +97,66 @@ class ReadFileToolInvocation extends BaseToolInvocation<
},
};
}
try {
const result = await processSingleFileContent(
this.resolvedPath,
this.config.getTargetDir(),
this.config.getFileSystemService(),
this.params.start_line,
this.params.end_line,
);
if (result.error) {
return {
llmContent: result.llmContent,
returnDisplay: result.returnDisplay || 'Error reading file',
error: {
message: result.error,
type: result.errorType,
},
};
}
const result = await processSingleFileContent(
this.resolvedPath,
this.config.getTargetDir(),
this.config.getFileSystemService(),
this.params.start_line,
this.params.end_line,
);
let llmContent = result.llmContent;
if (result.isTruncated && typeof llmContent === 'string') {
const [startLine, endLine] = result.linesShown || [1, 0];
llmContent = `
IMPORTANT: The file content has been truncated.
Status: Showing lines ${startLine}-${endLine} of ${result.originalLineCount} total lines.
Action: To read more of the file, you can use the 'start_line' and 'end_line' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use start_line: ${
endLine + 1
}.
--- FILE CONTENT (truncated) ---
${llmContent}
`;
}
const programming_language = getProgrammingLanguage({
file_path: this.resolvedPath,
});
logFileOperation(
this.config,
new FileOperationEvent(
this._toolName || READ_FILE_TOOL_NAME,
FileOperation.READ,
result.originalLineCount,
getSpecificMimeType(this.resolvedPath),
path.extname(this.resolvedPath),
programming_language,
),
);
const finalResult: ToolResult = {
llmContent,
returnDisplay: result.returnDisplay || '',
};
return finalResult;
} catch (err: unknown) {
const error = err instanceof Error ? err : new Error(String(err));
const errorMessage = String(error.message);
const toolResult: ToolResult = {
llmContent: [
{
text: `Error reading file: ${errorMessage}`,
},
],
returnDisplay: `Error: ${errorMessage}`,
if (result.error) {
return {
llmContent: result.llmContent,
returnDisplay: result.returnDisplay || 'Error reading file',
error: {
message: errorMessage,
type: ToolErrorType.EXECUTION_FAILED,
message: result.error,
type: result.errorType,
},
};
return toolResult;
}
let llmContent: PartUnion;
if (result.isTruncated) {
const [start, end] = result.linesShown!;
const total = result.originalLineCount!;
llmContent = `
IMPORTANT: The file content has been truncated.
Status: Showing lines ${start}-${end} of ${total} total lines.
Action: To read more of the file, you can use the 'start_line' and 'end_line' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use start_line: ${end + 1}.
--- FILE CONTENT (truncated) ---
${result.llmContent}`;
} else {
llmContent = result.llmContent || '';
}
const lines =
typeof result.llmContent === 'string'
? result.llmContent.split('\n').length
: undefined;
const mimetype = getSpecificMimeType(this.resolvedPath);
const programming_language = getProgrammingLanguage({
file_path: this.resolvedPath,
});
logFileOperation(
this.config,
new FileOperationEvent(
READ_FILE_TOOL_NAME,
FileOperation.READ,
lines,
mimetype,
path.extname(this.resolvedPath),
programming_language,
),
);
return {
llmContent,
returnDisplay: result.returnDisplay || '',
};
}
}
@@ -205,9 +183,6 @@ export class ReadFileTool extends BaseDeclarativeTool<
messageBus,
true,
false,
undefined,
undefined,
true,
);
this.fileDiscoveryService = new FileDiscoveryService(
config.getTargetDir(),
@@ -218,30 +193,15 @@ export class ReadFileTool extends BaseDeclarativeTool<
protected override validateToolParamValues(
params: ReadFileToolParams,
): string | null {
if (!params.file_path) {
if (params.file_path.trim() === '') {
return "The 'file_path' parameter must be non-empty.";
}
if (params.start_line !== undefined && params.start_line < 1) {
return 'start_line must be at least 1';
}
if (params.end_line !== undefined && params.end_line < 1) {
return 'end_line must be at least 1';
}
if (
params.start_line !== undefined &&
params.end_line !== undefined &&
params.start_line > params.end_line
) {
return 'start_line cannot be greater than end_line';
}
const resolvedPath = path.resolve(
this.config.getTargetDir(),
params.file_path,
);
const validationError = this.config.validatePathAccess(
resolvedPath,
'read',
@@ -250,6 +210,20 @@ export class ReadFileTool extends BaseDeclarativeTool<
return validationError;
}
if (params.start_line !== undefined && params.start_line < 1) {
return 'start_line must be at least 1';
}
if (params.end_line !== undefined && params.end_line < 1) {
return 'end_line must be at least 1';
}
if (
params.start_line !== undefined &&
params.end_line !== undefined &&
params.start_line > params.end_line
) {
return 'start_line cannot be greater than end_line';
}
const fileFilteringOptions = this.config.getFileFilteringOptions();
if (
this.fileDiscoveryService.shouldIgnoreFile(
@@ -268,7 +242,6 @@ export class ReadFileTool extends BaseDeclarativeTool<
messageBus: MessageBus,
_toolName?: string,
_toolDisplayName?: string,
isSensitive?: boolean,
): ToolInvocation<ReadFileToolParams, ToolResult> {
return new ReadFileToolInvocation(
this.config,
@@ -276,7 +249,6 @@ export class ReadFileTool extends BaseDeclarativeTool<
messageBus,
_toolName,
_toolDisplayName,
isSensitive,
);
}
+1
View File
@@ -16,6 +16,7 @@ class TestToolInvocation implements ToolInvocation<object, ToolResult> {
constructor(
readonly params: object,
private readonly executeFn: () => Promise<ToolResult>,
readonly isSensitive: boolean = false,
) {}
getDescription(): string {
+5
View File
@@ -33,6 +33,11 @@ export interface ToolInvocation<
*/
params: TParams;
/**
* Whether the tool is sensitive and requires specific policy approvals.
*/
isSensitive: boolean;
/**
* Gets a pre-execution description of the tool operation.
*
+2 -2
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { expand } from 'dotenv-expand';
import { expand, type DotenvExpandOutput } from 'dotenv-expand';
/**
* Expands environment variables in a string using the provided environment record.
@@ -45,7 +45,7 @@ export function expandEnvVars(
}
}
const result = expand({
const result: DotenvExpandOutput = expand({
parsed: { [dummyKey]: processedStr },
processEnv,
});
+1 -2
View File
@@ -8,10 +8,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { GeminiCliAgent } from './agent.js';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const __dirname = path.dirname(__filename);
// Set this to true locally when you need to update snapshots
const RECORD_MODE = process.env['RECORD_NEW_RESPONSES'] === 'true';
+5 -2
View File
@@ -4,8 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config as CoreConfig } from '@google/gemini-cli-core';
import { ShellExecutionService, ShellTool } from '@google/gemini-cli-core';
import {
ShellExecutionService,
ShellTool,
type Config as CoreConfig,
} from '@google/gemini-cli-core';
import type {
AgentShell,
AgentShellResult,
+1 -2
View File
@@ -9,10 +9,9 @@ import { GeminiCliAgent } from './agent.js';
import { skillDir } from './skills.js';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const __dirname = path.dirname(__filename);
// Set this to true locally when you need to update snapshots
const RECORD_MODE = process.env['RECORD_NEW_RESPONSES'] === 'true';
+1 -2
View File
@@ -10,10 +10,9 @@ import * as path from 'node:path';
import { z } from 'zod';
import { tool, ModelVisibleError } from './tool.js';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const __dirname = path.dirname(__filename);
// Set this to true locally when you need to update snapshots
const RECORD_MODE = process.env['RECORD_NEW_RESPONSES'] === 'true';
+1 -2
View File
@@ -6,13 +6,12 @@
import { expect } from 'vitest';
import { execSync, spawn, type ChildProcess } from 'node:child_process';
import { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import fs, { mkdirSync, writeFileSync, readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { env } from 'node:process';
import { setTimeout as sleep } from 'node:timers/promises';
import { DEFAULT_GEMINI_MODEL, GEMINI_DIR } from '@google/gemini-cli-core';
import fs from 'node:fs';
import * as pty from '@lydell/node-pty';
import stripAnsi from 'strip-ansi';
import * as os from 'node:os';
-7
View File
@@ -1451,13 +1451,6 @@
"default": false,
"type": "boolean"
},
"autoAddPolicy": {
"title": "Auto-add to Policy",
"description": "Automatically add \"Proceed always\" approvals to your persistent policy.",
"markdownDescription": "Automatically add \"Proceed always\" approvals to your persistent policy.\n\n- Category: `Security`\n- Requires restart: `no`\n- Default: `true`",
"default": true,
"type": "boolean"
},
"blockGitExtensions": {
"title": "Blocks extensions from Git",
"description": "Blocks installing and loading extensions from Git.",