mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 12:54:07 -07:00
feat(extensions): enforce folder trust for local extension install (#19703)
This commit is contained in:
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { TestRig, InteractiveRun } from './test-helper.js';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
import {
|
||||||
|
writeFileSync,
|
||||||
|
mkdirSync,
|
||||||
|
symlinkSync,
|
||||||
|
readFileSync,
|
||||||
|
unlinkSync,
|
||||||
|
} from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { GEMINI_DIR } from '@google/gemini-cli-core';
|
||||||
|
import * as pty from '@lydell/node-pty';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const BUNDLE_PATH = join(__dirname, '..', 'bundle/gemini.js');
|
||||||
|
|
||||||
|
const extension = `{
|
||||||
|
"name": "test-symlink-extension",
|
||||||
|
"version": "0.0.1"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const otherExtension = `{
|
||||||
|
"name": "malicious-extension",
|
||||||
|
"version": "6.6.6"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
describe('extension symlink install spoofing protection', () => {
|
||||||
|
let rig: TestRig;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
rig = new TestRig();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => await rig.cleanup());
|
||||||
|
|
||||||
|
it('canonicalizes the trust path and prevents symlink spoofing', async () => {
|
||||||
|
// Enable folder trust for this test
|
||||||
|
rig.setup('symlink spoofing test', {
|
||||||
|
settings: {
|
||||||
|
security: {
|
||||||
|
folderTrust: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const realExtPath = join(rig.testDir!, 'real-extension');
|
||||||
|
mkdirSync(realExtPath);
|
||||||
|
writeFileSync(join(realExtPath, 'gemini-extension.json'), extension);
|
||||||
|
|
||||||
|
const maliciousExtPath = join(
|
||||||
|
os.tmpdir(),
|
||||||
|
`malicious-extension-${Date.now()}`,
|
||||||
|
);
|
||||||
|
mkdirSync(maliciousExtPath);
|
||||||
|
writeFileSync(
|
||||||
|
join(maliciousExtPath, 'gemini-extension.json'),
|
||||||
|
otherExtension,
|
||||||
|
);
|
||||||
|
|
||||||
|
const symlinkPath = join(rig.testDir!, 'symlink-extension');
|
||||||
|
symlinkSync(realExtPath, symlinkPath);
|
||||||
|
|
||||||
|
// Function to run a command with a PTY to avoid headless mode
|
||||||
|
const runPty = (args: string[]) => {
|
||||||
|
const ptyProcess = pty.spawn(process.execPath, [BUNDLE_PATH, ...args], {
|
||||||
|
name: 'xterm-color',
|
||||||
|
cols: 80,
|
||||||
|
rows: 80,
|
||||||
|
cwd: rig.testDir!,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
GEMINI_CLI_HOME: rig.homeDir!,
|
||||||
|
GEMINI_CLI_INTEGRATION_TEST: 'true',
|
||||||
|
GEMINI_PTY_INFO: 'node-pty',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return new InteractiveRun(ptyProcess);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. Install via symlink, trust it
|
||||||
|
const run1 = runPty(['extensions', 'install', symlinkPath]);
|
||||||
|
await run1.expectText('Do you want to trust this folder', 30000);
|
||||||
|
await run1.type('y\r');
|
||||||
|
await run1.expectText('trust this workspace', 30000);
|
||||||
|
await run1.type('y\r');
|
||||||
|
await run1.expectText('Do you want to continue', 30000);
|
||||||
|
await run1.type('y\r');
|
||||||
|
await run1.expectText('installed successfully', 30000);
|
||||||
|
await run1.kill();
|
||||||
|
|
||||||
|
// 2. Verify trustedFolders.json contains the REAL path, not the symlink path
|
||||||
|
const trustedFoldersPath = join(
|
||||||
|
rig.homeDir!,
|
||||||
|
GEMINI_DIR,
|
||||||
|
'trustedFolders.json',
|
||||||
|
);
|
||||||
|
// Wait for file to be written
|
||||||
|
let attempts = 0;
|
||||||
|
while (!fs.existsSync(trustedFoldersPath) && attempts < 50) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
attempts++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trustedFolders = JSON.parse(
|
||||||
|
readFileSync(trustedFoldersPath, 'utf-8'),
|
||||||
|
);
|
||||||
|
const trustedPaths = Object.keys(trustedFolders);
|
||||||
|
const canonicalRealExtPath = fs.realpathSync(realExtPath);
|
||||||
|
|
||||||
|
expect(trustedPaths).toContain(canonicalRealExtPath);
|
||||||
|
expect(trustedPaths).not.toContain(symlinkPath);
|
||||||
|
|
||||||
|
// 3. Swap the symlink to point to the malicious extension
|
||||||
|
unlinkSync(symlinkPath);
|
||||||
|
symlinkSync(maliciousExtPath, symlinkPath);
|
||||||
|
|
||||||
|
// 4. Try to install again via the same symlink path.
|
||||||
|
// It should NOT be trusted because the real path changed.
|
||||||
|
const run2 = runPty(['extensions', 'install', symlinkPath]);
|
||||||
|
await run2.expectText('Do you want to trust this folder', 30000);
|
||||||
|
await run2.type('n\r');
|
||||||
|
await run2.expectText('Installation aborted', 30000);
|
||||||
|
await run2.kill();
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
@@ -16,14 +16,20 @@ import {
|
|||||||
} from 'vitest';
|
} from 'vitest';
|
||||||
import { handleInstall, installCommand } from './install.js';
|
import { handleInstall, installCommand } from './install.js';
|
||||||
import yargs from 'yargs';
|
import yargs from 'yargs';
|
||||||
import { debugLogger, type GeminiCLIExtension } from '@google/gemini-cli-core';
|
import * as core from '@google/gemini-cli-core';
|
||||||
|
import type { inferInstallMetadata } from '../../config/extension-manager.js';
|
||||||
|
import { ExtensionManager } from '../../config/extension-manager.js';
|
||||||
import type {
|
import type {
|
||||||
ExtensionManager,
|
promptForConsentNonInteractive,
|
||||||
inferInstallMetadata,
|
requestConsentNonInteractive,
|
||||||
} from '../../config/extension-manager.js';
|
} from '../../config/extensions/consent.js';
|
||||||
import type { requestConsentNonInteractive } from '../../config/extensions/consent.js';
|
import type {
|
||||||
|
isWorkspaceTrusted,
|
||||||
|
loadTrustedFolders,
|
||||||
|
} from '../../config/trustedFolders.js';
|
||||||
import type * as fs from 'node:fs/promises';
|
import type * as fs from 'node:fs/promises';
|
||||||
import type { Stats } from 'node:fs';
|
import type { Stats } from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
const mockInstallOrUpdateExtension: Mock<
|
const mockInstallOrUpdateExtension: Mock<
|
||||||
typeof ExtensionManager.prototype.installOrUpdateExtension
|
typeof ExtensionManager.prototype.installOrUpdateExtension
|
||||||
@@ -31,28 +37,54 @@ const mockInstallOrUpdateExtension: Mock<
|
|||||||
const mockRequestConsentNonInteractive: Mock<
|
const mockRequestConsentNonInteractive: Mock<
|
||||||
typeof requestConsentNonInteractive
|
typeof requestConsentNonInteractive
|
||||||
> = vi.hoisted(() => vi.fn());
|
> = vi.hoisted(() => vi.fn());
|
||||||
|
const mockPromptForConsentNonInteractive: Mock<
|
||||||
|
typeof promptForConsentNonInteractive
|
||||||
|
> = vi.hoisted(() => vi.fn());
|
||||||
const mockStat: Mock<typeof fs.stat> = vi.hoisted(() => vi.fn());
|
const mockStat: Mock<typeof fs.stat> = vi.hoisted(() => vi.fn());
|
||||||
const mockInferInstallMetadata: Mock<typeof inferInstallMetadata> = vi.hoisted(
|
const mockInferInstallMetadata: Mock<typeof inferInstallMetadata> = vi.hoisted(
|
||||||
() => vi.fn(),
|
() => vi.fn(),
|
||||||
);
|
);
|
||||||
|
const mockIsWorkspaceTrusted: Mock<typeof isWorkspaceTrusted> = vi.hoisted(() =>
|
||||||
|
vi.fn(),
|
||||||
|
);
|
||||||
|
const mockLoadTrustedFolders: Mock<typeof loadTrustedFolders> = vi.hoisted(() =>
|
||||||
|
vi.fn(),
|
||||||
|
);
|
||||||
|
const mockDiscover: Mock<typeof core.FolderTrustDiscoveryService.discover> =
|
||||||
|
vi.hoisted(() => vi.fn());
|
||||||
|
|
||||||
vi.mock('../../config/extensions/consent.js', () => ({
|
vi.mock('../../config/extensions/consent.js', () => ({
|
||||||
requestConsentNonInteractive: mockRequestConsentNonInteractive,
|
requestConsentNonInteractive: mockRequestConsentNonInteractive,
|
||||||
|
promptForConsentNonInteractive: mockPromptForConsentNonInteractive,
|
||||||
|
INSTALL_WARNING_MESSAGE: 'warning',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../config/extension-manager.js', async (importOriginal) => {
|
vi.mock('../../config/trustedFolders.js', () => ({
|
||||||
|
isWorkspaceTrusted: mockIsWorkspaceTrusted,
|
||||||
|
loadTrustedFolders: mockLoadTrustedFolders,
|
||||||
|
TrustLevel: {
|
||||||
|
TRUST_FOLDER: 'TRUST_FOLDER',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
|
||||||
const actual =
|
const actual =
|
||||||
await importOriginal<typeof import('../../config/extension-manager.js')>();
|
await importOriginal<typeof import('@google/gemini-cli-core')>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
ExtensionManager: vi.fn().mockImplementation(() => ({
|
FolderTrustDiscoveryService: {
|
||||||
installOrUpdateExtension: mockInstallOrUpdateExtension,
|
discover: mockDiscover,
|
||||||
loadExtensions: vi.fn(),
|
},
|
||||||
})),
|
|
||||||
inferInstallMetadata: mockInferInstallMetadata,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
vi.mock('../../config/extension-manager.js', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<
|
||||||
|
typeof import('../../config/extension-manager.js')
|
||||||
|
>()),
|
||||||
|
inferInstallMetadata: mockInferInstallMetadata,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../utils/errors.js', () => ({
|
vi.mock('../../utils/errors.js', () => ({
|
||||||
getErrorMessage: vi.fn((error: Error) => error.message),
|
getErrorMessage: vi.fn((error: Error) => error.message),
|
||||||
}));
|
}));
|
||||||
@@ -83,12 +115,31 @@ describe('handleInstall', () => {
|
|||||||
let processSpy: MockInstance;
|
let processSpy: MockInstance;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
debugLogSpy = vi.spyOn(debugLogger, 'log');
|
debugLogSpy = vi.spyOn(core.debugLogger, 'log');
|
||||||
debugErrorSpy = vi.spyOn(debugLogger, 'error');
|
debugErrorSpy = vi.spyOn(core.debugLogger, 'error');
|
||||||
processSpy = vi
|
processSpy = vi
|
||||||
.spyOn(process, 'exit')
|
.spyOn(process, 'exit')
|
||||||
.mockImplementation(() => undefined as never);
|
.mockImplementation(() => undefined as never);
|
||||||
|
|
||||||
|
vi.spyOn(ExtensionManager.prototype, 'loadExtensions').mockResolvedValue(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
vi.spyOn(
|
||||||
|
ExtensionManager.prototype,
|
||||||
|
'installOrUpdateExtension',
|
||||||
|
).mockImplementation(mockInstallOrUpdateExtension);
|
||||||
|
|
||||||
|
mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
|
||||||
|
mockDiscover.mockResolvedValue({
|
||||||
|
commands: [],
|
||||||
|
mcps: [],
|
||||||
|
hooks: [],
|
||||||
|
skills: [],
|
||||||
|
settings: [],
|
||||||
|
securityWarnings: [],
|
||||||
|
discoveryErrors: [],
|
||||||
|
});
|
||||||
|
|
||||||
mockInferInstallMetadata.mockImplementation(async (source, args) => {
|
mockInferInstallMetadata.mockImplementation(async (source, args) => {
|
||||||
if (
|
if (
|
||||||
source.startsWith('http://') ||
|
source.startsWith('http://') ||
|
||||||
@@ -114,12 +165,29 @@ describe('handleInstall', () => {
|
|||||||
mockStat.mockClear();
|
mockStat.mockClear();
|
||||||
mockInferInstallMetadata.mockClear();
|
mockInferInstallMetadata.mockClear();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function createMockExtension(
|
||||||
|
overrides: Partial<core.GeminiCLIExtension> = {},
|
||||||
|
): core.GeminiCLIExtension {
|
||||||
|
return {
|
||||||
|
name: 'mock-extension',
|
||||||
|
version: '1.0.0',
|
||||||
|
isActive: true,
|
||||||
|
path: '/mock/path',
|
||||||
|
contextFiles: [],
|
||||||
|
id: 'mock-id',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
it('should install an extension from a http source', async () => {
|
it('should install an extension from a http source', async () => {
|
||||||
mockInstallOrUpdateExtension.mockResolvedValue({
|
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||||
name: 'http-extension',
|
createMockExtension({
|
||||||
} as unknown as GeminiCLIExtension);
|
name: 'http-extension',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await handleInstall({
|
await handleInstall({
|
||||||
source: 'http://google.com',
|
source: 'http://google.com',
|
||||||
@@ -131,9 +199,11 @@ describe('handleInstall', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should install an extension from a https source', async () => {
|
it('should install an extension from a https source', async () => {
|
||||||
mockInstallOrUpdateExtension.mockResolvedValue({
|
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||||
name: 'https-extension',
|
createMockExtension({
|
||||||
} as unknown as GeminiCLIExtension);
|
name: 'https-extension',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await handleInstall({
|
await handleInstall({
|
||||||
source: 'https://google.com',
|
source: 'https://google.com',
|
||||||
@@ -145,9 +215,11 @@ describe('handleInstall', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should install an extension from a git source', async () => {
|
it('should install an extension from a git source', async () => {
|
||||||
mockInstallOrUpdateExtension.mockResolvedValue({
|
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||||
name: 'git-extension',
|
createMockExtension({
|
||||||
} as unknown as GeminiCLIExtension);
|
name: 'git-extension',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await handleInstall({
|
await handleInstall({
|
||||||
source: 'git@some-url',
|
source: 'git@some-url',
|
||||||
@@ -171,9 +243,11 @@ describe('handleInstall', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should install an extension from a sso source', async () => {
|
it('should install an extension from a sso source', async () => {
|
||||||
mockInstallOrUpdateExtension.mockResolvedValue({
|
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||||
name: 'sso-extension',
|
createMockExtension({
|
||||||
} as unknown as GeminiCLIExtension);
|
name: 'sso-extension',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
await handleInstall({
|
await handleInstall({
|
||||||
source: 'sso://google.com',
|
source: 'sso://google.com',
|
||||||
@@ -185,12 +259,14 @@ describe('handleInstall', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should install an extension from a local path', async () => {
|
it('should install an extension from a local path', async () => {
|
||||||
mockInstallOrUpdateExtension.mockResolvedValue({
|
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||||
name: 'local-extension',
|
createMockExtension({
|
||||||
} as unknown as GeminiCLIExtension);
|
name: 'local-extension',
|
||||||
|
}),
|
||||||
|
);
|
||||||
mockStat.mockResolvedValue({} as Stats);
|
mockStat.mockResolvedValue({} as Stats);
|
||||||
await handleInstall({
|
await handleInstall({
|
||||||
source: '/some/path',
|
source: path.join('/', 'some', 'path'),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(debugLogSpy).toHaveBeenCalledWith(
|
expect(debugLogSpy).toHaveBeenCalledWith(
|
||||||
@@ -208,4 +284,144 @@ describe('handleInstall', () => {
|
|||||||
expect(debugErrorSpy).toHaveBeenCalledWith('Install extension failed');
|
expect(debugErrorSpy).toHaveBeenCalledWith('Install extension failed');
|
||||||
expect(processSpy).toHaveBeenCalledWith(1);
|
expect(processSpy).toHaveBeenCalledWith(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should proceed if local path is already trusted', async () => {
|
||||||
|
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||||
|
createMockExtension({
|
||||||
|
name: 'local-extension',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mockStat.mockResolvedValue({} as Stats);
|
||||||
|
mockIsWorkspaceTrusted.mockReturnValue({ isTrusted: true, source: 'file' });
|
||||||
|
|
||||||
|
await handleInstall({
|
||||||
|
source: path.join('/', 'some', 'path'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
|
||||||
|
expect(mockPromptForConsentNonInteractive).not.toHaveBeenCalled();
|
||||||
|
expect(debugLogSpy).toHaveBeenCalledWith(
|
||||||
|
'Extension "local-extension" installed successfully and enabled.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prompt and proceed if user accepts trust', async () => {
|
||||||
|
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||||
|
createMockExtension({
|
||||||
|
name: 'local-extension',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mockStat.mockResolvedValue({} as Stats);
|
||||||
|
mockIsWorkspaceTrusted.mockReturnValue({
|
||||||
|
isTrusted: undefined,
|
||||||
|
source: undefined,
|
||||||
|
});
|
||||||
|
mockPromptForConsentNonInteractive.mockResolvedValue(true);
|
||||||
|
const mockSetValue = vi.fn();
|
||||||
|
mockLoadTrustedFolders.mockReturnValue({
|
||||||
|
setValue: mockSetValue,
|
||||||
|
user: { path: '', config: {} },
|
||||||
|
errors: [],
|
||||||
|
rules: [],
|
||||||
|
isPathTrusted: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleInstall({
|
||||||
|
source: path.join('/', 'untrusted', 'path'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
|
||||||
|
expect(mockPromptForConsentNonInteractive).toHaveBeenCalled();
|
||||||
|
expect(mockSetValue).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining(path.join('untrusted', 'path')),
|
||||||
|
'TRUST_FOLDER',
|
||||||
|
);
|
||||||
|
expect(debugLogSpy).toHaveBeenCalledWith(
|
||||||
|
'Extension "local-extension" installed successfully and enabled.',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prompt and abort if user denies trust', async () => {
|
||||||
|
mockStat.mockResolvedValue({} as Stats);
|
||||||
|
mockIsWorkspaceTrusted.mockReturnValue({
|
||||||
|
isTrusted: undefined,
|
||||||
|
source: undefined,
|
||||||
|
});
|
||||||
|
mockPromptForConsentNonInteractive.mockResolvedValue(false);
|
||||||
|
|
||||||
|
await handleInstall({
|
||||||
|
source: path.join('/', 'evil', 'path'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockIsWorkspaceTrusted).toHaveBeenCalled();
|
||||||
|
expect(mockPromptForConsentNonInteractive).toHaveBeenCalled();
|
||||||
|
expect(debugErrorSpy).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Installation aborted: Folder'),
|
||||||
|
);
|
||||||
|
expect(processSpy).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include discovery results in trust prompt', async () => {
|
||||||
|
mockInstallOrUpdateExtension.mockResolvedValue(
|
||||||
|
createMockExtension({
|
||||||
|
name: 'local-extension',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
mockStat.mockResolvedValue({} as Stats);
|
||||||
|
mockIsWorkspaceTrusted.mockReturnValue({
|
||||||
|
isTrusted: undefined,
|
||||||
|
source: undefined,
|
||||||
|
});
|
||||||
|
mockDiscover.mockResolvedValue({
|
||||||
|
commands: ['custom-cmd'],
|
||||||
|
mcps: [],
|
||||||
|
hooks: [],
|
||||||
|
skills: ['cool-skill'],
|
||||||
|
settings: [],
|
||||||
|
securityWarnings: ['Security risk!'],
|
||||||
|
discoveryErrors: ['Read error'],
|
||||||
|
});
|
||||||
|
mockPromptForConsentNonInteractive.mockResolvedValue(true);
|
||||||
|
mockLoadTrustedFolders.mockReturnValue({
|
||||||
|
setValue: vi.fn(),
|
||||||
|
user: { path: '', config: {} },
|
||||||
|
errors: [],
|
||||||
|
rules: [],
|
||||||
|
isPathTrusted: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await handleInstall({
|
||||||
|
source: '/untrusted/path',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('This folder contains:'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('custom-cmd'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('cool-skill'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Security Warnings:'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Security risk!'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Discovery Errors:'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
expect(mockPromptForConsentNonInteractive).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('Read error'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
// Implementation completed.
|
||||||
|
|||||||
@@ -5,10 +5,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CommandModule } from 'yargs';
|
import type { CommandModule } from 'yargs';
|
||||||
import { debugLogger } from '@google/gemini-cli-core';
|
import chalk from 'chalk';
|
||||||
|
import {
|
||||||
|
debugLogger,
|
||||||
|
FolderTrustDiscoveryService,
|
||||||
|
getRealPath,
|
||||||
|
} from '@google/gemini-cli-core';
|
||||||
import { getErrorMessage } from '../../utils/errors.js';
|
import { getErrorMessage } from '../../utils/errors.js';
|
||||||
import {
|
import {
|
||||||
INSTALL_WARNING_MESSAGE,
|
INSTALL_WARNING_MESSAGE,
|
||||||
|
promptForConsentNonInteractive,
|
||||||
requestConsentNonInteractive,
|
requestConsentNonInteractive,
|
||||||
} from '../../config/extensions/consent.js';
|
} from '../../config/extensions/consent.js';
|
||||||
import {
|
import {
|
||||||
@@ -16,6 +22,11 @@ import {
|
|||||||
inferInstallMetadata,
|
inferInstallMetadata,
|
||||||
} from '../../config/extension-manager.js';
|
} from '../../config/extension-manager.js';
|
||||||
import { loadSettings } from '../../config/settings.js';
|
import { loadSettings } from '../../config/settings.js';
|
||||||
|
import {
|
||||||
|
isWorkspaceTrusted,
|
||||||
|
loadTrustedFolders,
|
||||||
|
TrustLevel,
|
||||||
|
} from '../../config/trustedFolders.js';
|
||||||
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
import { promptForSetting } from '../../config/extensions/extensionSettings.js';
|
||||||
import { exitCli } from '../utils.js';
|
import { exitCli } from '../utils.js';
|
||||||
|
|
||||||
@@ -36,6 +47,95 @@ export async function handleInstall(args: InstallArgs) {
|
|||||||
allowPreRelease: args.allowPreRelease,
|
allowPreRelease: args.allowPreRelease,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const workspaceDir = process.cwd();
|
||||||
|
const settings = loadSettings(workspaceDir).merged;
|
||||||
|
|
||||||
|
if (installMetadata.type === 'local' || installMetadata.type === 'link') {
|
||||||
|
const resolvedPath = getRealPath(source);
|
||||||
|
installMetadata.source = resolvedPath;
|
||||||
|
const trustResult = isWorkspaceTrusted(settings, resolvedPath);
|
||||||
|
if (trustResult.isTrusted !== true) {
|
||||||
|
const discoveryResults =
|
||||||
|
await FolderTrustDiscoveryService.discover(resolvedPath);
|
||||||
|
|
||||||
|
const hasDiscovery =
|
||||||
|
discoveryResults.commands.length > 0 ||
|
||||||
|
discoveryResults.mcps.length > 0 ||
|
||||||
|
discoveryResults.hooks.length > 0 ||
|
||||||
|
discoveryResults.skills.length > 0 ||
|
||||||
|
discoveryResults.settings.length > 0;
|
||||||
|
|
||||||
|
const promptLines = [
|
||||||
|
'',
|
||||||
|
chalk.bold('Do you trust the files in this folder?'),
|
||||||
|
'',
|
||||||
|
`The extension source at "${resolvedPath}" is not trusted.`,
|
||||||
|
'',
|
||||||
|
'Trusting a folder allows Gemini CLI to load its local configurations,',
|
||||||
|
'including custom commands, hooks, MCP servers, agent skills, and',
|
||||||
|
'settings. These configurations could execute code on your behalf or',
|
||||||
|
'change the behavior of the CLI.',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
|
||||||
|
if (discoveryResults.discoveryErrors.length > 0) {
|
||||||
|
promptLines.push(chalk.red('❌ Discovery Errors:'));
|
||||||
|
for (const error of discoveryResults.discoveryErrors) {
|
||||||
|
promptLines.push(chalk.red(` • ${error}`));
|
||||||
|
}
|
||||||
|
promptLines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discoveryResults.securityWarnings.length > 0) {
|
||||||
|
promptLines.push(chalk.yellow('⚠️ Security Warnings:'));
|
||||||
|
for (const warning of discoveryResults.securityWarnings) {
|
||||||
|
promptLines.push(chalk.yellow(` • ${warning}`));
|
||||||
|
}
|
||||||
|
promptLines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasDiscovery) {
|
||||||
|
promptLines.push(chalk.bold('This folder contains:'));
|
||||||
|
const groups = [
|
||||||
|
{ label: 'Commands', items: discoveryResults.commands },
|
||||||
|
{ label: 'MCP Servers', items: discoveryResults.mcps },
|
||||||
|
{ label: 'Hooks', items: discoveryResults.hooks },
|
||||||
|
{ label: 'Skills', items: discoveryResults.skills },
|
||||||
|
{ label: 'Setting overrides', items: discoveryResults.settings },
|
||||||
|
].filter((g) => g.items.length > 0);
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
promptLines.push(
|
||||||
|
` • ${chalk.bold(group.label)} (${group.items.length}):`,
|
||||||
|
);
|
||||||
|
for (const item of group.items) {
|
||||||
|
promptLines.push(` - ${item}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
promptLines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
promptLines.push(
|
||||||
|
chalk.yellow(
|
||||||
|
'Do you want to trust this folder and continue with the installation? [y/N]: ',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirmed = await promptForConsentNonInteractive(
|
||||||
|
promptLines.join('\n'),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if (confirmed) {
|
||||||
|
const trustedFolders = loadTrustedFolders();
|
||||||
|
await trustedFolders.setValue(resolvedPath, TrustLevel.TRUST_FOLDER);
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Installation aborted: Folder "${resolvedPath}" is not trusted.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const requestConsent = args.consent
|
const requestConsent = args.consent
|
||||||
? () => Promise.resolve(true)
|
? () => Promise.resolve(true)
|
||||||
: requestConsentNonInteractive;
|
: requestConsentNonInteractive;
|
||||||
@@ -44,12 +144,11 @@ export async function handleInstall(args: InstallArgs) {
|
|||||||
debugLogger.log(INSTALL_WARNING_MESSAGE);
|
debugLogger.log(INSTALL_WARNING_MESSAGE);
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaceDir = process.cwd();
|
|
||||||
const extensionManager = new ExtensionManager({
|
const extensionManager = new ExtensionManager({
|
||||||
workspaceDir,
|
workspaceDir,
|
||||||
requestConsent,
|
requestConsent,
|
||||||
requestSetting: promptForSetting,
|
requestSetting: promptForSetting,
|
||||||
settings: loadSettings(workspaceDir).merged,
|
settings,
|
||||||
});
|
});
|
||||||
await extensionManager.loadExtensions();
|
await extensionManager.loadExtensions();
|
||||||
const extension =
|
const extension =
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import {
|
|||||||
ExtensionUninstallEvent,
|
ExtensionUninstallEvent,
|
||||||
ExtensionUpdateEvent,
|
ExtensionUpdateEvent,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
|
getRealPath,
|
||||||
logExtensionDisable,
|
logExtensionDisable,
|
||||||
logExtensionEnable,
|
logExtensionEnable,
|
||||||
logExtensionInstallEvent,
|
logExtensionInstallEvent,
|
||||||
@@ -202,13 +203,11 @@ export class ExtensionManager extends ExtensionLoader {
|
|||||||
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
|
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
|
||||||
await fs.promises.mkdir(extensionsDir, { recursive: true });
|
await fs.promises.mkdir(extensionsDir, { recursive: true });
|
||||||
|
|
||||||
if (
|
if (installMetadata.type === 'local' || installMetadata.type === 'link') {
|
||||||
!path.isAbsolute(installMetadata.source) &&
|
installMetadata.source = getRealPath(
|
||||||
(installMetadata.type === 'local' || installMetadata.type === 'link')
|
path.isAbsolute(installMetadata.source)
|
||||||
) {
|
? installMetadata.source
|
||||||
installMetadata.source = path.resolve(
|
: path.resolve(this.workspaceDir, installMetadata.source),
|
||||||
this.workspaceDir,
|
|
||||||
installMetadata.source,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
KeychainTokenStorage,
|
KeychainTokenStorage,
|
||||||
loadAgentsFromDirectory,
|
loadAgentsFromDirectory,
|
||||||
loadSkillsFromDir,
|
loadSkillsFromDir,
|
||||||
|
getRealPath,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
import {
|
import {
|
||||||
loadSettings,
|
loadSettings,
|
||||||
@@ -186,11 +187,11 @@ describe('extension tests', () => {
|
|||||||
errors: [],
|
errors: [],
|
||||||
});
|
});
|
||||||
vi.mocked(loadSkillsFromDir).mockResolvedValue([]);
|
vi.mocked(loadSkillsFromDir).mockResolvedValue([]);
|
||||||
tempHomeDir = fs.mkdtempSync(
|
tempHomeDir = getRealPath(
|
||||||
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
|
fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-home-')),
|
||||||
);
|
);
|
||||||
tempWorkspaceDir = fs.mkdtempSync(
|
tempWorkspaceDir = getRealPath(
|
||||||
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
|
fs.mkdtempSync(path.join(tempHomeDir, 'gemini-cli-test-workspace-')),
|
||||||
);
|
);
|
||||||
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
|
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
|
||||||
mockRequestConsent = vi.fn();
|
mockRequestConsent = vi.fn();
|
||||||
@@ -329,12 +330,14 @@ describe('extension tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should load a linked extension correctly', async () => {
|
it('should load a linked extension correctly', async () => {
|
||||||
const sourceExtDir = createExtension({
|
const sourceExtDir = getRealPath(
|
||||||
extensionsDir: tempWorkspaceDir,
|
createExtension({
|
||||||
name: 'my-linked-extension',
|
extensionsDir: tempWorkspaceDir,
|
||||||
version: '1.0.0',
|
name: 'my-linked-extension',
|
||||||
contextFileName: 'context.md',
|
version: '1.0.0',
|
||||||
});
|
contextFileName: 'context.md',
|
||||||
|
}),
|
||||||
|
);
|
||||||
fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context');
|
fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context');
|
||||||
|
|
||||||
await extensionManager.loadExtensions();
|
await extensionManager.loadExtensions();
|
||||||
@@ -361,18 +364,20 @@ describe('extension tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should hydrate ${extensionPath} correctly for linked extensions', async () => {
|
it('should hydrate ${extensionPath} correctly for linked extensions', async () => {
|
||||||
const sourceExtDir = createExtension({
|
const sourceExtDir = getRealPath(
|
||||||
extensionsDir: tempWorkspaceDir,
|
createExtension({
|
||||||
name: 'my-linked-extension-with-path',
|
extensionsDir: tempWorkspaceDir,
|
||||||
version: '1.0.0',
|
name: 'my-linked-extension-with-path',
|
||||||
mcpServers: {
|
version: '1.0.0',
|
||||||
'test-server': {
|
mcpServers: {
|
||||||
command: 'node',
|
'test-server': {
|
||||||
args: ['${extensionPath}${/}server${/}index.js'],
|
command: 'node',
|
||||||
cwd: '${extensionPath}${/}server',
|
args: ['${extensionPath}${/}server${/}index.js'],
|
||||||
|
cwd: '${extensionPath}${/}server',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}),
|
||||||
});
|
);
|
||||||
|
|
||||||
await extensionManager.loadExtensions();
|
await extensionManager.loadExtensions();
|
||||||
await extensionManager.installOrUpdateExtension({
|
await extensionManager.installOrUpdateExtension({
|
||||||
@@ -844,11 +849,13 @@ describe('extension tests', () => {
|
|||||||
|
|
||||||
it('should generate id from the original source for linked extensions', async () => {
|
it('should generate id from the original source for linked extensions', async () => {
|
||||||
const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions');
|
const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions');
|
||||||
const actualExtensionDir = createExtension({
|
const actualExtensionDir = getRealPath(
|
||||||
extensionsDir: extDevelopmentDir,
|
createExtension({
|
||||||
name: 'link-ext-name',
|
extensionsDir: extDevelopmentDir,
|
||||||
version: '1.0.0',
|
name: 'link-ext-name',
|
||||||
});
|
version: '1.0.0',
|
||||||
|
}),
|
||||||
|
);
|
||||||
await extensionManager.loadExtensions();
|
await extensionManager.loadExtensions();
|
||||||
await extensionManager.installOrUpdateExtension({
|
await extensionManager.installOrUpdateExtension({
|
||||||
type: 'link',
|
type: 'link',
|
||||||
@@ -994,11 +1001,13 @@ describe('extension tests', () => {
|
|||||||
|
|
||||||
describe('installExtension', () => {
|
describe('installExtension', () => {
|
||||||
it('should install an extension from a local path', async () => {
|
it('should install an extension from a local path', async () => {
|
||||||
const sourceExtDir = createExtension({
|
const sourceExtDir = getRealPath(
|
||||||
extensionsDir: tempHomeDir,
|
createExtension({
|
||||||
name: 'my-local-extension',
|
extensionsDir: tempHomeDir,
|
||||||
version: '1.0.0',
|
name: 'my-local-extension',
|
||||||
});
|
version: '1.0.0',
|
||||||
|
}),
|
||||||
|
);
|
||||||
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
|
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
|
||||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||||
|
|
||||||
@@ -1040,7 +1049,7 @@ describe('extension tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error and cleanup if gemini-extension.json is missing', async () => {
|
it('should throw an error and cleanup if gemini-extension.json is missing', async () => {
|
||||||
const sourceExtDir = path.join(tempHomeDir, 'bad-extension');
|
const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-extension'));
|
||||||
fs.mkdirSync(sourceExtDir, { recursive: true });
|
fs.mkdirSync(sourceExtDir, { recursive: true });
|
||||||
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
|
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||||
|
|
||||||
@@ -1056,7 +1065,7 @@ describe('extension tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error for invalid JSON in gemini-extension.json', async () => {
|
it('should throw an error for invalid JSON in gemini-extension.json', async () => {
|
||||||
const sourceExtDir = path.join(tempHomeDir, 'bad-json-ext');
|
const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-json-ext'));
|
||||||
fs.mkdirSync(sourceExtDir, { recursive: true });
|
fs.mkdirSync(sourceExtDir, { recursive: true });
|
||||||
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
|
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||||
fs.writeFileSync(configPath, '{ "name": "bad-json", "version": "1.0.0"'); // Malformed JSON
|
fs.writeFileSync(configPath, '{ "name": "bad-json", "version": "1.0.0"'); // Malformed JSON
|
||||||
@@ -1066,22 +1075,17 @@ describe('extension tests', () => {
|
|||||||
source: sourceExtDir,
|
source: sourceExtDir,
|
||||||
type: 'local',
|
type: 'local',
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow(
|
).rejects.toThrow(`Failed to load extension config from ${configPath}`);
|
||||||
new RegExp(
|
|
||||||
`^Failed to load extension config from ${configPath.replace(
|
|
||||||
/\\/g,
|
|
||||||
'\\\\',
|
|
||||||
)}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw an error for missing name in gemini-extension.json', async () => {
|
it('should throw an error for missing name in gemini-extension.json', async () => {
|
||||||
const sourceExtDir = createExtension({
|
const sourceExtDir = getRealPath(
|
||||||
extensionsDir: tempHomeDir,
|
createExtension({
|
||||||
name: 'missing-name-ext',
|
extensionsDir: tempHomeDir,
|
||||||
version: '1.0.0',
|
name: 'missing-name-ext',
|
||||||
});
|
version: '1.0.0',
|
||||||
|
}),
|
||||||
|
);
|
||||||
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
|
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||||
// Overwrite with invalid config
|
// Overwrite with invalid config
|
||||||
fs.writeFileSync(configPath, JSON.stringify({ version: '1.0.0' }));
|
fs.writeFileSync(configPath, JSON.stringify({ version: '1.0.0' }));
|
||||||
@@ -1134,11 +1138,13 @@ describe('extension tests', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should install a linked extension', async () => {
|
it('should install a linked extension', async () => {
|
||||||
const sourceExtDir = createExtension({
|
const sourceExtDir = getRealPath(
|
||||||
extensionsDir: tempHomeDir,
|
createExtension({
|
||||||
name: 'my-linked-extension',
|
extensionsDir: tempHomeDir,
|
||||||
version: '1.0.0',
|
name: 'my-linked-extension',
|
||||||
});
|
version: '1.0.0',
|
||||||
|
}),
|
||||||
|
);
|
||||||
const targetExtDir = path.join(userExtensionsDir, 'my-linked-extension');
|
const targetExtDir = path.join(userExtensionsDir, 'my-linked-extension');
|
||||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||||
const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME);
|
const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME);
|
||||||
@@ -1439,11 +1445,13 @@ ${INSTALL_WARNING_MESSAGE}`,
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should save the autoUpdate flag to the install metadata', async () => {
|
it('should save the autoUpdate flag to the install metadata', async () => {
|
||||||
const sourceExtDir = createExtension({
|
const sourceExtDir = getRealPath(
|
||||||
extensionsDir: tempHomeDir,
|
createExtension({
|
||||||
name: 'my-local-extension',
|
extensionsDir: tempHomeDir,
|
||||||
version: '1.0.0',
|
name: 'my-local-extension',
|
||||||
});
|
version: '1.0.0',
|
||||||
|
}),
|
||||||
|
);
|
||||||
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
|
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
|
||||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ describe('consent', () => {
|
|||||||
{ input: '', expected: true },
|
{ input: '', expected: true },
|
||||||
{ input: 'n', expected: false },
|
{ input: 'n', expected: false },
|
||||||
{ input: 'N', expected: false },
|
{ input: 'N', expected: false },
|
||||||
{ input: 'yes', expected: false },
|
{ input: 'yes', expected: true },
|
||||||
])(
|
])(
|
||||||
'should return $expected for input "$input"',
|
'should return $expected for input "$input"',
|
||||||
async ({ input, expected }) => {
|
async ({ input, expected }) => {
|
||||||
|
|||||||
@@ -91,10 +91,12 @@ export async function requestConsentInteractive(
|
|||||||
* This should not be called from interactive mode as it will break the CLI.
|
* This should not be called from interactive mode as it will break the CLI.
|
||||||
*
|
*
|
||||||
* @param prompt A yes/no prompt to ask the user
|
* @param prompt A yes/no prompt to ask the user
|
||||||
* @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter.
|
* @param defaultValue Whether to resolve as true or false on enter.
|
||||||
|
* @returns Whether or not the user answers 'y' (yes).
|
||||||
*/
|
*/
|
||||||
async function promptForConsentNonInteractive(
|
export async function promptForConsentNonInteractive(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
|
defaultValue = true,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const readline = await import('node:readline');
|
const readline = await import('node:readline');
|
||||||
const rl = readline.createInterface({
|
const rl = readline.createInterface({
|
||||||
@@ -105,7 +107,12 @@ async function promptForConsentNonInteractive(
|
|||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
rl.question(prompt, (answer) => {
|
rl.question(prompt, (answer) => {
|
||||||
rl.close();
|
rl.close();
|
||||||
resolve(['y', ''].includes(answer.trim().toLowerCase()));
|
const trimmedAnswer = answer.trim().toLowerCase();
|
||||||
|
if (trimmedAnswer === '') {
|
||||||
|
resolve(defaultValue);
|
||||||
|
} else {
|
||||||
|
resolve(['y', 'yes'].includes(trimmedAnswer));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ import {
|
|||||||
SettingScope,
|
SettingScope,
|
||||||
LoadedSettings,
|
LoadedSettings,
|
||||||
sanitizeEnvVar,
|
sanitizeEnvVar,
|
||||||
|
createTestMergedSettings,
|
||||||
} from './settings.js';
|
} from './settings.js';
|
||||||
import {
|
import {
|
||||||
FatalConfigError,
|
FatalConfigError,
|
||||||
@@ -1838,36 +1839,50 @@ describe('Settings Loading and Merging', () => {
|
|||||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not load env files from untrusted spaces', () => {
|
it('does not load env files from untrusted spaces when sandboxed', () => {
|
||||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||||
const settings = {
|
const settings = {
|
||||||
security: { folderTrust: { enabled: true } },
|
security: { folderTrust: { enabled: true } },
|
||||||
|
tools: { sandbox: true },
|
||||||
} as Settings;
|
} as Settings;
|
||||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||||
|
|
||||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not load env files when trust is undefined', () => {
|
it('does load env files from untrusted spaces when NOT sandboxed', () => {
|
||||||
|
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||||
|
const settings = {
|
||||||
|
security: { folderTrust: { enabled: true } },
|
||||||
|
tools: { sandbox: false },
|
||||||
|
} as Settings;
|
||||||
|
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||||
|
|
||||||
|
expect(process.env['TESTTEST']).toEqual('1234');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not load env files when trust is undefined and sandboxed', () => {
|
||||||
delete process.env['TESTTEST'];
|
delete process.env['TESTTEST'];
|
||||||
// isWorkspaceTrusted returns {isTrusted: undefined} for matched rules with no trust value, or no matching rules.
|
// isWorkspaceTrusted returns {isTrusted: undefined} for matched rules with no trust value, or no matching rules.
|
||||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: undefined });
|
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: undefined });
|
||||||
const settings = {
|
const settings = {
|
||||||
security: { folderTrust: { enabled: true } },
|
security: { folderTrust: { enabled: true } },
|
||||||
|
tools: { sandbox: true },
|
||||||
} as Settings;
|
} as Settings;
|
||||||
|
|
||||||
const mockTrustFn = vi.fn().mockReturnValue({ isTrusted: undefined });
|
const mockTrustFn = vi.fn().mockReturnValue({ isTrusted: undefined });
|
||||||
loadEnvironment(settings, MOCK_WORKSPACE_DIR, mockTrustFn);
|
loadEnvironment(settings, MOCK_WORKSPACE_DIR, mockTrustFn);
|
||||||
|
|
||||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||||
expect(process.env['GEMINI_API_KEY']).not.toEqual('test-key');
|
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loads whitelisted env files from untrusted spaces if sandboxing is enabled', () => {
|
it('loads whitelisted env files from untrusted spaces if sandboxing is enabled', () => {
|
||||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
const settings = createTestMergedSettings({
|
||||||
settings.merged.tools.sandbox = true;
|
tools: { sandbox: true },
|
||||||
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
|
});
|
||||||
|
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||||
|
|
||||||
// GEMINI_API_KEY is in the whitelist, so it should be loaded.
|
// GEMINI_API_KEY is in the whitelist, so it should be loaded.
|
||||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||||
@@ -1880,10 +1895,10 @@ describe('Settings Loading and Merging', () => {
|
|||||||
process.argv.push('-s');
|
process.argv.push('-s');
|
||||||
try {
|
try {
|
||||||
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
|
||||||
const settings = loadSettings(MOCK_WORKSPACE_DIR);
|
const settings = createTestMergedSettings({
|
||||||
// Ensure sandbox is NOT in settings to test argv sniffing
|
tools: { sandbox: false },
|
||||||
settings.merged.tools.sandbox = undefined;
|
});
|
||||||
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
|
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
|
||||||
|
|
||||||
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
|
||||||
expect(process.env['TESTTEST']).not.toEqual('1234');
|
expect(process.env['TESTTEST']).not.toEqual('1234');
|
||||||
@@ -2782,7 +2797,7 @@ describe('Settings Loading and Merging', () => {
|
|||||||
MOCK_WORKSPACE_DIR,
|
MOCK_WORKSPACE_DIR,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
expect(process.env['GEMINI_API_KEY']).toEqual('secret');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should NOT be tricked by positional arguments that look like flags', () => {
|
it('should NOT be tricked by positional arguments that look like flags', () => {
|
||||||
@@ -2801,7 +2816,7 @@ describe('Settings Loading and Merging', () => {
|
|||||||
MOCK_WORKSPACE_DIR,
|
MOCK_WORKSPACE_DIR,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
|
expect(process.env['GEMINI_API_KEY']).toEqual('secret');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -573,10 +573,6 @@ export function loadEnvironment(
|
|||||||
relevantArgs.includes('-s') ||
|
relevantArgs.includes('-s') ||
|
||||||
relevantArgs.includes('--sandbox');
|
relevantArgs.includes('--sandbox');
|
||||||
|
|
||||||
if (trustResult.isTrusted !== true && !isSandboxed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cloud Shell environment variable handling
|
// Cloud Shell environment variable handling
|
||||||
if (process.env['CLOUD_SHELL'] === 'true') {
|
if (process.env['CLOUD_SHELL'] === 'true') {
|
||||||
setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed);
|
setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed);
|
||||||
|
|||||||
Reference in New Issue
Block a user