Add support for trustedFolders.json config file (#6073)
This commit is contained in:
parent
b61a63aef4
commit
38876b738f
|
@ -13,6 +13,11 @@ import { loadCliConfig, parseArguments } from './config.js';
|
||||||
import { Settings } from './settings.js';
|
import { Settings } from './settings.js';
|
||||||
import { Extension } from './extension.js';
|
import { Extension } from './extension.js';
|
||||||
import * as ServerConfig from '@google/gemini-cli-core';
|
import * as ServerConfig from '@google/gemini-cli-core';
|
||||||
|
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||||
|
|
||||||
|
vi.mock('./trustedFolders.js', () => ({
|
||||||
|
isWorkspaceTrusted: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('os', async (importOriginal) => {
|
vi.mock('os', async (importOriginal) => {
|
||||||
const actualOs = await importOriginal<typeof os>();
|
const actualOs = await importOriginal<typeof os>();
|
||||||
|
@ -1628,6 +1633,7 @@ describe('loadCliConfig approval mode', () => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||||
process.env.GEMINI_API_KEY = 'test-api-key';
|
process.env.GEMINI_API_KEY = 'test-api-key';
|
||||||
|
process.argv = ['node', 'script.js']; // Reset argv for each test
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@ -1696,3 +1702,120 @@ describe('loadCliConfig approval mode', () => {
|
||||||
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO);
|
expect(config.getApprovalMode()).toBe(ServerConfig.ApprovalMode.YOLO);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('loadCliConfig trustedFolder', () => {
|
||||||
|
const originalArgv = process.argv;
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
vi.mocked(os.homedir).mockReturnValue('/mock/home/user');
|
||||||
|
process.env.GEMINI_API_KEY = 'test-api-key';
|
||||||
|
process.argv = ['node', 'script.js']; // Reset argv for each test
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.argv = originalArgv;
|
||||||
|
process.env = originalEnv;
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const testCases = [
|
||||||
|
// Cases where folderTrustFeature is false (feature disabled)
|
||||||
|
{
|
||||||
|
folderTrustFeature: false,
|
||||||
|
folderTrust: true,
|
||||||
|
isWorkspaceTrusted: true,
|
||||||
|
expectedFolderTrust: false,
|
||||||
|
expectedIsTrustedFolder: true,
|
||||||
|
description:
|
||||||
|
'feature disabled, folderTrust true, workspace trusted -> behave as trusted',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
folderTrustFeature: false,
|
||||||
|
folderTrust: true,
|
||||||
|
isWorkspaceTrusted: false,
|
||||||
|
expectedFolderTrust: false,
|
||||||
|
expectedIsTrustedFolder: true,
|
||||||
|
description:
|
||||||
|
'feature disabled, folderTrust true, workspace not trusted -> behave as trusted',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
folderTrustFeature: false,
|
||||||
|
folderTrust: false,
|
||||||
|
isWorkspaceTrusted: true,
|
||||||
|
expectedFolderTrust: false,
|
||||||
|
expectedIsTrustedFolder: true,
|
||||||
|
description:
|
||||||
|
'feature disabled, folderTrust false, workspace trusted -> behave as trusted',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cases where folderTrustFeature is true but folderTrust setting is false
|
||||||
|
{
|
||||||
|
folderTrustFeature: true,
|
||||||
|
folderTrust: false,
|
||||||
|
isWorkspaceTrusted: true,
|
||||||
|
expectedFolderTrust: false,
|
||||||
|
expectedIsTrustedFolder: true,
|
||||||
|
description:
|
||||||
|
'feature on, folderTrust false, workspace trusted -> behave as trusted',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
folderTrustFeature: true,
|
||||||
|
folderTrust: false,
|
||||||
|
isWorkspaceTrusted: false,
|
||||||
|
expectedFolderTrust: false,
|
||||||
|
expectedIsTrustedFolder: true,
|
||||||
|
description:
|
||||||
|
'feature on, folderTrust false, workspace not trusted -> behave as trusted',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Cases where feature is fully enabled (folderTrustFeature and folderTrust are true)
|
||||||
|
{
|
||||||
|
folderTrustFeature: true,
|
||||||
|
folderTrust: true,
|
||||||
|
isWorkspaceTrusted: true,
|
||||||
|
expectedFolderTrust: true,
|
||||||
|
expectedIsTrustedFolder: true,
|
||||||
|
description:
|
||||||
|
'feature on, folderTrust on, workspace trusted -> is trusted',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
folderTrustFeature: true,
|
||||||
|
folderTrust: true,
|
||||||
|
isWorkspaceTrusted: false,
|
||||||
|
expectedFolderTrust: true,
|
||||||
|
expectedIsTrustedFolder: false,
|
||||||
|
description:
|
||||||
|
'feature on, folderTrust on, workspace NOT trusted -> is NOT trusted',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
folderTrustFeature: true,
|
||||||
|
folderTrust: true,
|
||||||
|
isWorkspaceTrusted: undefined,
|
||||||
|
expectedFolderTrust: true,
|
||||||
|
expectedIsTrustedFolder: undefined,
|
||||||
|
description:
|
||||||
|
'feature on, folderTrust on, workspace trust unknown -> is unknown',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const {
|
||||||
|
folderTrustFeature,
|
||||||
|
folderTrust,
|
||||||
|
isWorkspaceTrusted: mockTrustValue,
|
||||||
|
expectedFolderTrust,
|
||||||
|
expectedIsTrustedFolder,
|
||||||
|
description,
|
||||||
|
} of testCases) {
|
||||||
|
it(`should be correct for: ${description}`, async () => {
|
||||||
|
(isWorkspaceTrusted as vi.Mock).mockReturnValue(mockTrustValue);
|
||||||
|
const argv = await parseArguments();
|
||||||
|
const settings: Settings = { folderTrustFeature, folderTrust };
|
||||||
|
const config = await loadCliConfig(settings, [], 'test-session', argv);
|
||||||
|
|
||||||
|
expect(config.getFolderTrust()).toBe(expectedFolderTrust);
|
||||||
|
expect(config.isTrustedFolder()).toBe(expectedIsTrustedFolder);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
@ -35,6 +35,8 @@ import { getCliVersion } from '../utils/version.js';
|
||||||
import { loadSandboxConfig } from './sandboxConfig.js';
|
import { loadSandboxConfig } from './sandboxConfig.js';
|
||||||
import { resolvePath } from '../utils/resolvePath.js';
|
import { resolvePath } from '../utils/resolvePath.js';
|
||||||
|
|
||||||
|
import { isWorkspaceTrusted } from './trustedFolders.js';
|
||||||
|
|
||||||
// Simple console logger for now - replace with actual logger if available
|
// Simple console logger for now - replace with actual logger if available
|
||||||
const logger = {
|
const logger = {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
@ -317,8 +319,9 @@ export async function loadCliConfig(
|
||||||
const ideMode = settings.ideMode ?? false;
|
const ideMode = settings.ideMode ?? false;
|
||||||
|
|
||||||
const folderTrustFeature = settings.folderTrustFeature ?? false;
|
const folderTrustFeature = settings.folderTrustFeature ?? false;
|
||||||
const folderTrustSetting = settings.folderTrust ?? false;
|
const folderTrustSetting = settings.folderTrust ?? true;
|
||||||
const folderTrust = folderTrustFeature && folderTrustSetting;
|
const folderTrust = folderTrustFeature && folderTrustSetting;
|
||||||
|
const trustedFolder = folderTrust ? isWorkspaceTrusted() : true;
|
||||||
|
|
||||||
const allExtensions = annotateActiveExtensions(
|
const allExtensions = annotateActiveExtensions(
|
||||||
extensions,
|
extensions,
|
||||||
|
@ -523,6 +526,7 @@ export async function loadCliConfig(
|
||||||
folderTrustFeature,
|
folderTrustFeature,
|
||||||
folderTrust,
|
folderTrust,
|
||||||
interactive,
|
interactive,
|
||||||
|
trustedFolder,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,203 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mock 'os' first.
|
||||||
|
import * as osActual from 'os';
|
||||||
|
vi.mock('os', async (importOriginal) => {
|
||||||
|
const actualOs = await importOriginal<typeof osActual>();
|
||||||
|
return {
|
||||||
|
...actualOs,
|
||||||
|
homedir: vi.fn(() => '/mock/home/user'),
|
||||||
|
platform: vi.fn(() => 'linux'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import {
|
||||||
|
describe,
|
||||||
|
it,
|
||||||
|
expect,
|
||||||
|
vi,
|
||||||
|
beforeEach,
|
||||||
|
afterEach,
|
||||||
|
type Mocked,
|
||||||
|
type Mock,
|
||||||
|
} from 'vitest';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import stripJsonComments from 'strip-json-comments';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
loadTrustedFolders,
|
||||||
|
USER_TRUSTED_FOLDERS_PATH,
|
||||||
|
TrustLevel,
|
||||||
|
isWorkspaceTrusted,
|
||||||
|
} from './trustedFolders.js';
|
||||||
|
|
||||||
|
vi.mock('fs', async (importOriginal) => {
|
||||||
|
const actualFs = await importOriginal<typeof fs>();
|
||||||
|
return {
|
||||||
|
...actualFs,
|
||||||
|
existsSync: vi.fn(),
|
||||||
|
readFileSync: vi.fn(),
|
||||||
|
writeFileSync: vi.fn(),
|
||||||
|
mkdirSync: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('strip-json-comments', () => ({
|
||||||
|
default: vi.fn((content) => content),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('Trusted Folders Loading', () => {
|
||||||
|
let mockFsExistsSync: Mocked<typeof fs.existsSync>;
|
||||||
|
let mockStripJsonComments: Mocked<typeof stripJsonComments>;
|
||||||
|
let mockFsWriteFileSync: Mocked<typeof fs.writeFileSync>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
mockFsExistsSync = vi.mocked(fs.existsSync);
|
||||||
|
mockStripJsonComments = vi.mocked(stripJsonComments);
|
||||||
|
mockFsWriteFileSync = vi.mocked(fs.writeFileSync);
|
||||||
|
vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user');
|
||||||
|
(mockStripJsonComments as unknown as Mock).mockImplementation(
|
||||||
|
(jsonString: string) => jsonString,
|
||||||
|
);
|
||||||
|
(mockFsExistsSync as Mock).mockReturnValue(false);
|
||||||
|
(fs.readFileSync as Mock).mockReturnValue('{}');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load empty rules if no files exist', () => {
|
||||||
|
const { rules, errors } = loadTrustedFolders();
|
||||||
|
expect(rules).toEqual([]);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load user rules if only user file exists', () => {
|
||||||
|
const userPath = USER_TRUSTED_FOLDERS_PATH;
|
||||||
|
(mockFsExistsSync as Mock).mockImplementation((p) => p === userPath);
|
||||||
|
const userContent = {
|
||||||
|
'/user/folder': TrustLevel.TRUST_FOLDER,
|
||||||
|
};
|
||||||
|
(fs.readFileSync as Mock).mockImplementation((p) => {
|
||||||
|
if (p === userPath) return JSON.stringify(userContent);
|
||||||
|
return '{}';
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rules, errors } = loadTrustedFolders();
|
||||||
|
expect(rules).toEqual([
|
||||||
|
{ path: '/user/folder', trustLevel: TrustLevel.TRUST_FOLDER },
|
||||||
|
]);
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle JSON parsing errors gracefully', () => {
|
||||||
|
const userPath = USER_TRUSTED_FOLDERS_PATH;
|
||||||
|
(mockFsExistsSync as Mock).mockImplementation((p) => p === userPath);
|
||||||
|
(fs.readFileSync as Mock).mockImplementation((p) => {
|
||||||
|
if (p === userPath) return 'invalid json';
|
||||||
|
return '{}';
|
||||||
|
});
|
||||||
|
|
||||||
|
const { rules, errors } = loadTrustedFolders();
|
||||||
|
expect(rules).toEqual([]);
|
||||||
|
expect(errors.length).toBe(1);
|
||||||
|
expect(errors[0].path).toBe(userPath);
|
||||||
|
expect(errors[0].message).toContain('Unexpected token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setValue should update the user config and save it', () => {
|
||||||
|
const loadedFolders = loadTrustedFolders();
|
||||||
|
loadedFolders.setValue('/new/path', TrustLevel.TRUST_FOLDER);
|
||||||
|
|
||||||
|
expect(loadedFolders.user.config['/new/path']).toBe(
|
||||||
|
TrustLevel.TRUST_FOLDER,
|
||||||
|
);
|
||||||
|
expect(mockFsWriteFileSync).toHaveBeenCalledWith(
|
||||||
|
USER_TRUSTED_FOLDERS_PATH,
|
||||||
|
JSON.stringify({ '/new/path': TrustLevel.TRUST_FOLDER }, null, 2),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isWorkspaceTrusted', () => {
|
||||||
|
let mockCwd: string;
|
||||||
|
const mockRules: Record<string, TrustLevel> = {};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
|
||||||
|
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
|
||||||
|
if (p === USER_TRUSTED_FOLDERS_PATH) {
|
||||||
|
return JSON.stringify(mockRules);
|
||||||
|
}
|
||||||
|
return '{}';
|
||||||
|
});
|
||||||
|
vi.spyOn(fs, 'existsSync').mockImplementation(
|
||||||
|
(p) => p === USER_TRUSTED_FOLDERS_PATH,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
// Clear the object
|
||||||
|
Object.keys(mockRules).forEach((key) => delete mockRules[key]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for a directly trusted folder', () => {
|
||||||
|
mockCwd = '/home/user/projectA';
|
||||||
|
mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER;
|
||||||
|
expect(isWorkspaceTrusted()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for a child of a trusted folder', () => {
|
||||||
|
mockCwd = '/home/user/projectA/src';
|
||||||
|
mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER;
|
||||||
|
expect(isWorkspaceTrusted()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for a child of a trusted parent folder', () => {
|
||||||
|
mockCwd = '/home/user/projectB';
|
||||||
|
mockRules['/home/user/projectB/somefile.txt'] = TrustLevel.TRUST_PARENT;
|
||||||
|
expect(isWorkspaceTrusted()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for a directly untrusted folder', () => {
|
||||||
|
mockCwd = '/home/user/untrusted';
|
||||||
|
mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST;
|
||||||
|
expect(isWorkspaceTrusted()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for a child of an untrusted folder', () => {
|
||||||
|
mockCwd = '/home/user/untrusted/src';
|
||||||
|
mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST;
|
||||||
|
expect(isWorkspaceTrusted()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined when no rules match', () => {
|
||||||
|
mockCwd = '/home/user/other';
|
||||||
|
mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER;
|
||||||
|
mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST;
|
||||||
|
expect(isWorkspaceTrusted()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prioritize trust over distrust', () => {
|
||||||
|
mockCwd = '/home/user/projectA/untrusted';
|
||||||
|
mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER;
|
||||||
|
mockRules['/home/user/projectA/untrusted'] = TrustLevel.DO_NOT_TRUST;
|
||||||
|
expect(isWorkspaceTrusted()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle path normalization', () => {
|
||||||
|
mockCwd = '/home/user/projectA';
|
||||||
|
mockRules[`/home/user/../user/${path.basename('/home/user/projectA')}`] =
|
||||||
|
TrustLevel.TRUST_FOLDER;
|
||||||
|
expect(isWorkspaceTrusted()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,158 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { homedir } from 'os';
|
||||||
|
import { getErrorMessage, isWithinRoot } from '@google/gemini-cli-core';
|
||||||
|
import stripJsonComments from 'strip-json-comments';
|
||||||
|
|
||||||
|
export const TRUSTED_FOLDERS_FILENAME = 'trustedFolders.json';
|
||||||
|
export const SETTINGS_DIRECTORY_NAME = '.gemini';
|
||||||
|
export const USER_SETTINGS_DIR = path.join(homedir(), SETTINGS_DIRECTORY_NAME);
|
||||||
|
export const USER_TRUSTED_FOLDERS_PATH = path.join(
|
||||||
|
USER_SETTINGS_DIR,
|
||||||
|
TRUSTED_FOLDERS_FILENAME,
|
||||||
|
);
|
||||||
|
|
||||||
|
export enum TrustLevel {
|
||||||
|
TRUST_FOLDER = 'TRUST_FOLDER',
|
||||||
|
TRUST_PARENT = 'TRUST_PARENT',
|
||||||
|
DO_NOT_TRUST = 'DO_NOT_TRUST',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrustRule {
|
||||||
|
path: string;
|
||||||
|
trustLevel: TrustLevel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrustedFoldersError {
|
||||||
|
message: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrustedFoldersFile {
|
||||||
|
config: Record<string, TrustLevel>;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LoadedTrustedFolders {
|
||||||
|
constructor(
|
||||||
|
public user: TrustedFoldersFile,
|
||||||
|
public errors: TrustedFoldersError[],
|
||||||
|
) {}
|
||||||
|
|
||||||
|
get rules(): TrustRule[] {
|
||||||
|
return Object.entries(this.user.config).map(([path, trustLevel]) => ({
|
||||||
|
path,
|
||||||
|
trustLevel,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
setValue(path: string, trustLevel: TrustLevel): void {
|
||||||
|
this.user.config[path] = trustLevel;
|
||||||
|
saveTrustedFolders(this.user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadTrustedFolders(): LoadedTrustedFolders {
|
||||||
|
const errors: TrustedFoldersError[] = [];
|
||||||
|
const userConfig: Record<string, TrustLevel> = {};
|
||||||
|
|
||||||
|
const userPath = USER_TRUSTED_FOLDERS_PATH;
|
||||||
|
|
||||||
|
// Load user trusted folders
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(userPath)) {
|
||||||
|
const content = fs.readFileSync(userPath, 'utf-8');
|
||||||
|
const parsed = JSON.parse(stripJsonComments(content)) as Record<
|
||||||
|
string,
|
||||||
|
TrustLevel
|
||||||
|
>;
|
||||||
|
if (parsed) {
|
||||||
|
Object.assign(userConfig, parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
errors.push({
|
||||||
|
message: getErrorMessage(error),
|
||||||
|
path: userPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new LoadedTrustedFolders(
|
||||||
|
{ path: userPath, config: userConfig },
|
||||||
|
errors,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveTrustedFolders(
|
||||||
|
trustedFoldersFile: TrustedFoldersFile,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
// Ensure the directory exists
|
||||||
|
const dirPath = path.dirname(trustedFoldersFile.path);
|
||||||
|
if (!fs.existsSync(dirPath)) {
|
||||||
|
fs.mkdirSync(dirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
trustedFoldersFile.path,
|
||||||
|
JSON.stringify(trustedFoldersFile.config, null, 2),
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving trusted folders file:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorkspaceTrusted(): boolean | undefined {
|
||||||
|
const { rules, errors } = loadTrustedFolders();
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
for (const error of errors) {
|
||||||
|
console.error(
|
||||||
|
`Error loading trusted folders config from ${error.path}: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trustedPaths: string[] = [];
|
||||||
|
const untrustedPaths: string[] = [];
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
switch (rule.trustLevel) {
|
||||||
|
case TrustLevel.TRUST_FOLDER:
|
||||||
|
trustedPaths.push(rule.path);
|
||||||
|
break;
|
||||||
|
case TrustLevel.TRUST_PARENT:
|
||||||
|
trustedPaths.push(path.dirname(rule.path));
|
||||||
|
break;
|
||||||
|
case TrustLevel.DO_NOT_TRUST:
|
||||||
|
untrustedPaths.push(rule.path);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Do nothing for unknown trust levels.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cwd = process.cwd();
|
||||||
|
|
||||||
|
for (const trustedPath of trustedPaths) {
|
||||||
|
if (isWithinRoot(cwd, trustedPath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const untrustedPath of untrustedPaths) {
|
||||||
|
if (path.normalize(cwd) === path.normalize(untrustedPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
|
@ -252,8 +252,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
||||||
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
|
const { isSettingsDialogOpen, openSettingsDialog, closeSettingsDialog } =
|
||||||
useSettingsCommand();
|
useSettingsCommand();
|
||||||
|
|
||||||
const { isFolderTrustDialogOpen, handleFolderTrustSelect } =
|
const { isFolderTrustDialogOpen, handleFolderTrustSelect } = useFolderTrust(
|
||||||
useFolderTrust(settings);
|
settings,
|
||||||
|
config,
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isAuthDialogOpen,
|
isAuthDialogOpen,
|
||||||
|
|
|
@ -4,15 +4,33 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { renderHook, act } from '@testing-library/react';
|
|
||||||
import { vi } from 'vitest';
|
import { vi } from 'vitest';
|
||||||
|
import { renderHook, act } from '@testing-library/react';
|
||||||
import { useFolderTrust } from './useFolderTrust.js';
|
import { useFolderTrust } from './useFolderTrust.js';
|
||||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
import { type Config } from '@google/gemini-cli-core';
|
||||||
|
import { LoadedSettings } from '../../config/settings.js';
|
||||||
import { FolderTrustChoice } from '../components/FolderTrustDialog.js';
|
import { FolderTrustChoice } from '../components/FolderTrustDialog.js';
|
||||||
|
import {
|
||||||
|
LoadedTrustedFolders,
|
||||||
|
TrustLevel,
|
||||||
|
} from '../../config/trustedFolders.js';
|
||||||
|
import * as process from 'process';
|
||||||
|
|
||||||
|
import * as trustedFolders from '../../config/trustedFolders.js';
|
||||||
|
|
||||||
|
vi.mock('process', () => ({
|
||||||
|
cwd: vi.fn(),
|
||||||
|
platform: 'linux',
|
||||||
|
}));
|
||||||
|
|
||||||
describe('useFolderTrust', () => {
|
describe('useFolderTrust', () => {
|
||||||
it('should set isFolderTrustDialogOpen to true when folderTrustFeature is true and folderTrust is undefined', () => {
|
let mockSettings: LoadedSettings;
|
||||||
const settings = {
|
let mockConfig: Config;
|
||||||
|
let mockTrustedFolders: LoadedTrustedFolders;
|
||||||
|
let loadTrustedFoldersSpy: vi.SpyInstance;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockSettings = {
|
||||||
merged: {
|
merged: {
|
||||||
folderTrustFeature: true,
|
folderTrustFeature: true,
|
||||||
folderTrust: undefined,
|
folderTrust: undefined,
|
||||||
|
@ -20,59 +38,110 @@ describe('useFolderTrust', () => {
|
||||||
setValue: vi.fn(),
|
setValue: vi.fn(),
|
||||||
} as unknown as LoadedSettings;
|
} as unknown as LoadedSettings;
|
||||||
|
|
||||||
const { result } = renderHook(() => useFolderTrust(settings));
|
mockConfig = {
|
||||||
|
isTrustedFolder: vi.fn().mockReturnValue(undefined),
|
||||||
|
} as unknown as Config;
|
||||||
|
|
||||||
|
mockTrustedFolders = {
|
||||||
|
setValue: vi.fn(),
|
||||||
|
} as unknown as LoadedTrustedFolders;
|
||||||
|
|
||||||
|
loadTrustedFoldersSpy = vi
|
||||||
|
.spyOn(trustedFolders, 'loadTrustedFolders')
|
||||||
|
.mockReturnValue(mockTrustedFolders);
|
||||||
|
(process.cwd as vi.Mock).mockReturnValue('/test/path');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not open dialog when folder is already trusted', () => {
|
||||||
|
(mockConfig.isTrustedFolder as vi.Mock).mockReturnValue(true);
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useFolderTrust(mockSettings, mockConfig),
|
||||||
|
);
|
||||||
|
expect(result.current.isFolderTrustDialogOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not open dialog when folder is already untrusted', () => {
|
||||||
|
(mockConfig.isTrustedFolder as vi.Mock).mockReturnValue(false);
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useFolderTrust(mockSettings, mockConfig),
|
||||||
|
);
|
||||||
|
expect(result.current.isFolderTrustDialogOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should open dialog when folder trust is undefined', () => {
|
||||||
|
(mockConfig.isTrustedFolder as vi.Mock).mockReturnValue(undefined);
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useFolderTrust(mockSettings, mockConfig),
|
||||||
|
);
|
||||||
expect(result.current.isFolderTrustDialogOpen).toBe(true);
|
expect(result.current.isFolderTrustDialogOpen).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should set isFolderTrustDialogOpen to false when folderTrustFeature is false', () => {
|
it('should handle TRUST_FOLDER choice', () => {
|
||||||
const settings = {
|
const { result } = renderHook(() =>
|
||||||
merged: {
|
useFolderTrust(mockSettings, mockConfig),
|
||||||
folderTrustFeature: false,
|
);
|
||||||
folderTrust: undefined,
|
|
||||||
},
|
|
||||||
setValue: vi.fn(),
|
|
||||||
} as unknown as LoadedSettings;
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useFolderTrust(settings));
|
|
||||||
|
|
||||||
expect(result.current.isFolderTrustDialogOpen).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set isFolderTrustDialogOpen to false when folderTrust is defined', () => {
|
|
||||||
const settings = {
|
|
||||||
merged: {
|
|
||||||
folderTrustFeature: true,
|
|
||||||
folderTrust: true,
|
|
||||||
},
|
|
||||||
setValue: vi.fn(),
|
|
||||||
} as unknown as LoadedSettings;
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useFolderTrust(settings));
|
|
||||||
|
|
||||||
expect(result.current.isFolderTrustDialogOpen).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call setValue and set isFolderTrustDialogOpen to false on handleFolderTrustSelect', () => {
|
|
||||||
const settings = {
|
|
||||||
merged: {
|
|
||||||
folderTrustFeature: true,
|
|
||||||
folderTrust: undefined,
|
|
||||||
},
|
|
||||||
setValue: vi.fn(),
|
|
||||||
} as unknown as LoadedSettings;
|
|
||||||
|
|
||||||
const { result } = renderHook(() => useFolderTrust(settings));
|
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER);
|
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_FOLDER);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(settings.setValue).toHaveBeenCalledWith(
|
expect(loadTrustedFoldersSpy).toHaveBeenCalled();
|
||||||
SettingScope.User,
|
expect(mockTrustedFolders.setValue).toHaveBeenCalledWith(
|
||||||
'folderTrust',
|
'/test/path',
|
||||||
true,
|
TrustLevel.TRUST_FOLDER,
|
||||||
);
|
);
|
||||||
expect(result.current.isFolderTrustDialogOpen).toBe(false);
|
expect(result.current.isFolderTrustDialogOpen).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should handle TRUST_PARENT choice', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useFolderTrust(mockSettings, mockConfig),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleFolderTrustSelect(FolderTrustChoice.TRUST_PARENT);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockTrustedFolders.setValue).toHaveBeenCalledWith(
|
||||||
|
'/test/path',
|
||||||
|
TrustLevel.TRUST_PARENT,
|
||||||
|
);
|
||||||
|
expect(result.current.isFolderTrustDialogOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle DO_NOT_TRUST choice', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useFolderTrust(mockSettings, mockConfig),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleFolderTrustSelect(FolderTrustChoice.DO_NOT_TRUST);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockTrustedFolders.setValue).toHaveBeenCalledWith(
|
||||||
|
'/test/path',
|
||||||
|
TrustLevel.DO_NOT_TRUST,
|
||||||
|
);
|
||||||
|
expect(result.current.isFolderTrustDialogOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing for default choice', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useFolderTrust(mockSettings, mockConfig),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleFolderTrustSelect(
|
||||||
|
'invalid_choice' as FolderTrustChoice,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockTrustedFolders.setValue).not.toHaveBeenCalled();
|
||||||
|
expect(mockSettings.setValue).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.isFolderTrustDialogOpen).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -5,24 +5,39 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { LoadedSettings, SettingScope } from '../../config/settings.js';
|
import { type Config } from '@google/gemini-cli-core';
|
||||||
|
import { LoadedSettings } from '../../config/settings.js';
|
||||||
import { FolderTrustChoice } from '../components/FolderTrustDialog.js';
|
import { FolderTrustChoice } from '../components/FolderTrustDialog.js';
|
||||||
|
import { loadTrustedFolders, TrustLevel } from '../../config/trustedFolders.js';
|
||||||
|
import * as process from 'process';
|
||||||
|
|
||||||
export const useFolderTrust = (settings: LoadedSettings) => {
|
export const useFolderTrust = (settings: LoadedSettings, config: Config) => {
|
||||||
const [isFolderTrustDialogOpen, setIsFolderTrustDialogOpen] = useState(
|
const [isFolderTrustDialogOpen, setIsFolderTrustDialogOpen] = useState(
|
||||||
!!settings.merged.folderTrustFeature &&
|
config.isTrustedFolder() === undefined,
|
||||||
// TODO: Update to avoid showing dialog for folders that are trusted.
|
|
||||||
settings.merged.folderTrust === undefined,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFolderTrustSelect = useCallback(
|
const handleFolderTrustSelect = useCallback((choice: FolderTrustChoice) => {
|
||||||
(_choice: FolderTrustChoice) => {
|
const trustedFolders = loadTrustedFolders();
|
||||||
// TODO: Store folderPath in the trusted folders config file based on the choice.
|
const cwd = process.cwd();
|
||||||
settings.setValue(SettingScope.User, 'folderTrust', true);
|
let trustLevel: TrustLevel;
|
||||||
|
|
||||||
|
switch (choice) {
|
||||||
|
case FolderTrustChoice.TRUST_FOLDER:
|
||||||
|
trustLevel = TrustLevel.TRUST_FOLDER;
|
||||||
|
break;
|
||||||
|
case FolderTrustChoice.TRUST_PARENT:
|
||||||
|
trustLevel = TrustLevel.TRUST_PARENT;
|
||||||
|
break;
|
||||||
|
case FolderTrustChoice.DO_NOT_TRUST:
|
||||||
|
trustLevel = TrustLevel.DO_NOT_TRUST;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
trustedFolders.setValue(cwd, trustLevel);
|
||||||
setIsFolderTrustDialogOpen(false);
|
setIsFolderTrustDialogOpen(false);
|
||||||
},
|
}, []);
|
||||||
[settings],
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isFolderTrustDialogOpen,
|
isFolderTrustDialogOpen,
|
||||||
|
|
|
@ -197,6 +197,7 @@ export interface ConfigParameters {
|
||||||
loadMemoryFromIncludeDirectories?: boolean;
|
loadMemoryFromIncludeDirectories?: boolean;
|
||||||
chatCompression?: ChatCompressionSettings;
|
chatCompression?: ChatCompressionSettings;
|
||||||
interactive?: boolean;
|
interactive?: boolean;
|
||||||
|
trustedFolder?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Config {
|
export class Config {
|
||||||
|
@ -260,6 +261,7 @@ export class Config {
|
||||||
private readonly loadMemoryFromIncludeDirectories: boolean = false;
|
private readonly loadMemoryFromIncludeDirectories: boolean = false;
|
||||||
private readonly chatCompression: ChatCompressionSettings | undefined;
|
private readonly chatCompression: ChatCompressionSettings | undefined;
|
||||||
private readonly interactive: boolean;
|
private readonly interactive: boolean;
|
||||||
|
private readonly trustedFolder: boolean | undefined;
|
||||||
private initialized: boolean = false;
|
private initialized: boolean = false;
|
||||||
|
|
||||||
constructor(params: ConfigParameters) {
|
constructor(params: ConfigParameters) {
|
||||||
|
@ -324,6 +326,7 @@ export class Config {
|
||||||
params.loadMemoryFromIncludeDirectories ?? false;
|
params.loadMemoryFromIncludeDirectories ?? false;
|
||||||
this.chatCompression = params.chatCompression;
|
this.chatCompression = params.chatCompression;
|
||||||
this.interactive = params.interactive ?? false;
|
this.interactive = params.interactive ?? false;
|
||||||
|
this.trustedFolder = params.trustedFolder;
|
||||||
|
|
||||||
if (params.contextFileName) {
|
if (params.contextFileName) {
|
||||||
setGeminiMdFilename(params.contextFileName);
|
setGeminiMdFilename(params.contextFileName);
|
||||||
|
@ -664,6 +667,10 @@ export class Config {
|
||||||
return this.folderTrust;
|
return this.folderTrust;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isTrustedFolder(): boolean | undefined {
|
||||||
|
return this.trustedFolder;
|
||||||
|
}
|
||||||
|
|
||||||
setIdeMode(value: boolean): void {
|
setIdeMode(value: boolean): void {
|
||||||
this.ideMode = value;
|
this.ideMode = value;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue