summaryrefslogtreecommitdiff
path: root/packages/cli/src/config
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/config')
-rw-r--r--packages/cli/src/config/config.test.ts123
-rw-r--r--packages/cli/src/config/config.ts6
-rw-r--r--packages/cli/src/config/trustedFolders.test.ts203
-rw-r--r--packages/cli/src/config/trustedFolders.ts158
4 files changed, 489 insertions, 1 deletions
diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts
index fc4d24bd..69985867 100644
--- a/packages/cli/src/config/config.test.ts
+++ b/packages/cli/src/config/config.test.ts
@@ -13,6 +13,11 @@ import { loadCliConfig, parseArguments } from './config.js';
import { Settings } from './settings.js';
import { Extension } from './extension.js';
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) => {
const actualOs = await importOriginal<typeof os>();
@@ -1628,6 +1633,7 @@ describe('loadCliConfig approval mode', () => {
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(() => {
@@ -1696,3 +1702,120 @@ describe('loadCliConfig approval mode', () => {
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);
+ });
+ }
+});
diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts
index 636696fa..296d140d 100644
--- a/packages/cli/src/config/config.ts
+++ b/packages/cli/src/config/config.ts
@@ -35,6 +35,8 @@ import { getCliVersion } from '../utils/version.js';
import { loadSandboxConfig } from './sandboxConfig.js';
import { resolvePath } from '../utils/resolvePath.js';
+import { isWorkspaceTrusted } from './trustedFolders.js';
+
// Simple console logger for now - replace with actual logger if available
const logger = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -317,8 +319,9 @@ export async function loadCliConfig(
const ideMode = settings.ideMode ?? false;
const folderTrustFeature = settings.folderTrustFeature ?? false;
- const folderTrustSetting = settings.folderTrust ?? false;
+ const folderTrustSetting = settings.folderTrust ?? true;
const folderTrust = folderTrustFeature && folderTrustSetting;
+ const trustedFolder = folderTrust ? isWorkspaceTrusted() : true;
const allExtensions = annotateActiveExtensions(
extensions,
@@ -523,6 +526,7 @@ export async function loadCliConfig(
folderTrustFeature,
folderTrust,
interactive,
+ trustedFolder,
});
}
diff --git a/packages/cli/src/config/trustedFolders.test.ts b/packages/cli/src/config/trustedFolders.test.ts
new file mode 100644
index 00000000..67bf9cfc
--- /dev/null
+++ b/packages/cli/src/config/trustedFolders.test.ts
@@ -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);
+ });
+});
diff --git a/packages/cli/src/config/trustedFolders.ts b/packages/cli/src/config/trustedFolders.ts
new file mode 100644
index 00000000..9da27c80
--- /dev/null
+++ b/packages/cli/src/config/trustedFolders.ts
@@ -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;
+}