fix: enforce folder trust for workspace settings, skills, and context (#17596)

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
Gal Zahavi
2026-02-03 14:53:31 -08:00
committed by GitHub
parent d63c34b6e1
commit 71f46f1160
18 changed files with 1310 additions and 788 deletions
@@ -21,6 +21,17 @@ import * as path from 'node:path';
import * as os from 'node:os'; import * as os from 'node:os';
import { GEMINI_DIR, debugLogger } from '@google/gemini-cli-core'; import { GEMINI_DIR, debugLogger } from '@google/gemini-cli-core';
vi.mock('fs', async (importOriginal) => {
const actualFs = await importOriginal<typeof fs>();
return {
...actualFs,
existsSync: vi.fn(actualFs.existsSync),
readFileSync: vi.fn(actualFs.readFileSync),
writeFileSync: vi.fn(actualFs.writeFileSync),
mkdirSync: vi.fn(actualFs.mkdirSync),
};
});
vi.mock('fs/promises', () => ({ vi.mock('fs/promises', () => ({
readFile: vi.fn(), readFile: vi.fn(),
writeFile: vi.fn(), writeFile: vi.fn(),
@@ -30,6 +41,14 @@ vi.mock('../utils.js', () => ({
exitCli: vi.fn(), exitCli: vi.fn(),
})); }));
vi.mock('../../config/trustedFolders.js', () => ({
isWorkspaceTrusted: vi.fn(() => ({
isTrusted: true,
source: undefined,
})),
isFolderTrustEnabled: vi.fn(() => false),
}));
describe('mcp remove command', () => { describe('mcp remove command', () => {
describe('unit tests with mocks', () => { describe('unit tests with mocks', () => {
let parser: Argv; let parser: Argv;
+157 -1
View File
@@ -105,7 +105,7 @@ vi.mock('fs', async (importOriginal) => {
readFileSync: vi.fn(), readFileSync: vi.fn(),
writeFileSync: vi.fn(), writeFileSync: vi.fn(),
mkdirSync: vi.fn(), mkdirSync: vi.fn(),
realpathSync: (p: string) => p, realpathSync: vi.fn((p: string) => p),
}; };
}); });
@@ -119,9 +119,11 @@ const mockCoreEvents = vi.hoisted(() => ({
vi.mock('@google/gemini-cli-core', async (importOriginal) => { vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual = const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>(); await importOriginal<typeof import('@google/gemini-cli-core')>();
const os = await import('node:os');
return { return {
...actual, ...actual,
coreEvents: mockCoreEvents, coreEvents: mockCoreEvents,
homedir: vi.fn(() => os.homedir()),
}; };
}); });
@@ -1460,6 +1462,44 @@ describe('Settings Loading and Merging', () => {
}); });
}); });
}); });
it('should correctly skip workspace-level loading if workspaceDir is a symlink to home', () => {
const mockHomeDir = '/mock/home/user';
const mockSymlinkDir = '/mock/symlink/to/home';
const mockWorkspaceSettingsPath = path.join(
mockSymlinkDir,
GEMINI_DIR,
'settings.json',
);
vi.mocked(osActual.homedir).mockReturnValue(mockHomeDir);
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) => {
const pStr = p.toString();
const resolved = path.resolve(pStr);
if (
resolved === path.resolve(mockSymlinkDir) ||
resolved === path.resolve(mockHomeDir)
) {
return mockHomeDir;
}
return pStr;
});
(mockFsExistsSync as Mock).mockImplementation(
(p: string) =>
// Only return true for workspace settings path to see if it gets loaded
p === mockWorkspaceSettingsPath,
);
const settings = loadSettings(mockSymlinkDir);
// Verify that even though the file exists, it was NOT loaded because realpath matched home
expect(fs.readFileSync).not.toHaveBeenCalledWith(
mockWorkspaceSettingsPath,
'utf-8',
);
expect(settings.workspace.settings).toEqual({});
});
}); });
describe('excludedProjectEnvVars integration', () => { describe('excludedProjectEnvVars integration', () => {
@@ -2373,3 +2413,119 @@ describe('Settings Loading and Merging', () => {
}); });
}); });
}); });
describe('LoadedSettings Isolation and Serializability', () => {
let loadedSettings: LoadedSettings;
interface TestData {
a: {
b: number;
};
}
beforeEach(() => {
vi.resetAllMocks();
// Create a minimal LoadedSettings instance
const emptyScope = {
path: '/mock/settings.json',
settings: {},
originalSettings: {},
} as unknown as SettingsFile;
loadedSettings = new LoadedSettings(
emptyScope, // system
emptyScope, // systemDefaults
{ ...emptyScope }, // user
emptyScope, // workspace
true, // isTrusted
);
});
describe('setValue Isolation', () => {
it('should isolate state between settings and originalSettings', () => {
const complexValue: TestData = { a: { b: 1 } };
loadedSettings.setValue(SettingScope.User, 'test', complexValue);
const userSettings = loadedSettings.forScope(SettingScope.User);
const settingsValue = (userSettings.settings as Record<string, unknown>)[
'test'
] as TestData;
const originalValue = (
userSettings.originalSettings as Record<string, unknown>
)['test'] as TestData;
// Verify they are equal but different references
expect(settingsValue).toEqual(complexValue);
expect(originalValue).toEqual(complexValue);
expect(settingsValue).not.toBe(complexValue);
expect(originalValue).not.toBe(complexValue);
expect(settingsValue).not.toBe(originalValue);
// Modify the in-memory setting object
settingsValue.a.b = 2;
// originalSettings should NOT be affected
expect(originalValue.a.b).toBe(1);
});
it('should not share references between settings and originalSettings (original servers test)', () => {
const mcpServers = {
'test-server': { command: 'echo' },
};
loadedSettings.setValue(SettingScope.User, 'mcpServers', mcpServers);
// Modify the original object
delete (mcpServers as Record<string, unknown>)['test-server'];
// The settings in LoadedSettings should still have the server
const userSettings = loadedSettings.forScope(SettingScope.User);
expect(
(userSettings.settings.mcpServers as Record<string, unknown>)[
'test-server'
],
).toBeDefined();
expect(
(userSettings.originalSettings.mcpServers as Record<string, unknown>)[
'test-server'
],
).toBeDefined();
// They should also be different objects from each other
expect(userSettings.settings.mcpServers).not.toBe(
userSettings.originalSettings.mcpServers,
);
});
});
describe('setValue Serializability', () => {
it('should preserve Map/Set types (via structuredClone)', () => {
const mapValue = { myMap: new Map([['key', 'value']]) };
loadedSettings.setValue(SettingScope.User, 'test', mapValue);
const userSettings = loadedSettings.forScope(SettingScope.User);
const settingsValue = (userSettings.settings as Record<string, unknown>)[
'test'
] as { myMap: Map<string, string> };
// Map is preserved by structuredClone
expect(settingsValue.myMap).toBeInstanceOf(Map);
expect(settingsValue.myMap.get('key')).toBe('value');
// But it should be a different reference
expect(settingsValue.myMap).not.toBe(mapValue.myMap);
});
it('should handle circular references (structuredClone supports them, but deepMerge may not)', () => {
const circular: Record<string, unknown> = { a: 1 };
circular['self'] = circular;
// structuredClone(circular) works, but LoadedSettings.setValue calls
// computeMergedSettings() -> customDeepMerge() which blows up on circularity.
expect(() => {
loadedSettings.setValue(SettingScope.User, 'test', circular);
}).toThrow(/Maximum call stack size exceeded/);
});
});
});
+124 -119
View File
@@ -277,8 +277,11 @@ export class LoadedSettings {
this.system = system; this.system = system;
this.systemDefaults = systemDefaults; this.systemDefaults = systemDefaults;
this.user = user; this.user = user;
this.workspace = workspace; this._workspaceFile = workspace;
this.isTrusted = isTrusted; this.isTrusted = isTrusted;
this.workspace = isTrusted
? workspace
: this.createEmptyWorkspace(workspace);
this.errors = errors; this.errors = errors;
this._merged = this.computeMergedSettings(); this._merged = this.computeMergedSettings();
} }
@@ -286,10 +289,11 @@ export class LoadedSettings {
readonly system: SettingsFile; readonly system: SettingsFile;
readonly systemDefaults: SettingsFile; readonly systemDefaults: SettingsFile;
readonly user: SettingsFile; readonly user: SettingsFile;
readonly workspace: SettingsFile; workspace: SettingsFile;
readonly isTrusted: boolean; isTrusted: boolean;
readonly errors: SettingsError[]; readonly errors: SettingsError[];
private _workspaceFile: SettingsFile;
private _merged: MergedSettings; private _merged: MergedSettings;
private _remoteAdminSettings: Partial<Settings> | undefined; private _remoteAdminSettings: Partial<Settings> | undefined;
@@ -297,6 +301,26 @@ export class LoadedSettings {
return this._merged; return this._merged;
} }
setTrusted(isTrusted: boolean): void {
if (this.isTrusted === isTrusted) {
return;
}
this.isTrusted = isTrusted;
this.workspace = isTrusted
? this._workspaceFile
: this.createEmptyWorkspace(this._workspaceFile);
this._merged = this.computeMergedSettings();
coreEvents.emitSettingsChanged();
}
private createEmptyWorkspace(workspace: SettingsFile): SettingsFile {
return {
...workspace,
settings: {},
originalSettings: {},
};
}
private computeMergedSettings(): MergedSettings { private computeMergedSettings(): MergedSettings {
const merged = mergeSettings( const merged = mergeSettings(
this.system.settings, this.system.settings,
@@ -341,8 +365,21 @@ export class LoadedSettings {
setValue(scope: LoadableSettingScope, key: string, value: unknown): void { setValue(scope: LoadableSettingScope, key: string, value: unknown): void {
const settingsFile = this.forScope(scope); const settingsFile = this.forScope(scope);
setNestedProperty(settingsFile.settings, key, value);
setNestedProperty(settingsFile.originalSettings, key, value); // Clone value to prevent reference sharing between settings and originalSettings
const valueToSet =
typeof value === 'object' && value !== null
? structuredClone(value)
: value;
setNestedProperty(settingsFile.settings, key, valueToSet);
// Use a fresh clone for originalSettings to ensure total independence
setNestedProperty(
settingsFile.originalSettings,
key,
structuredClone(valueToSet),
);
this._merged = this.computeMergedSettings(); this._merged = this.computeMergedSettings();
saveSettings(settingsFile); saveSettings(settingsFile);
coreEvents.emitSettingsChanged(); coreEvents.emitSettingsChanged();
@@ -592,9 +629,10 @@ export function loadSettings(
// For the initial trust check, we can only use user and system settings. // For the initial trust check, we can only use user and system settings.
const initialTrustCheckSettings = customDeepMerge( const initialTrustCheckSettings = customDeepMerge(
getMergeStrategyForPath, getMergeStrategyForPath,
{}, getDefaultsFromSchema(),
systemSettings, systemDefaultSettings,
userSettings, userSettings,
systemSettings,
); );
const isTrusted = const isTrusted =
isWorkspaceTrusted(initialTrustCheckSettings as Settings, workspaceDir) isWorkspaceTrusted(initialTrustCheckSettings as Settings, workspaceDir)
@@ -672,57 +710,55 @@ export function migrateDeprecatedSettings(
removeDeprecated = false, removeDeprecated = false,
): boolean { ): boolean {
let anyModified = false; let anyModified = false;
const migrateBoolean = (
settings: Record<string, unknown>,
oldKey: string,
newKey: string,
): boolean => {
let modified = false;
const oldValue = settings[oldKey];
const newValue = settings[newKey];
if (typeof oldValue === 'boolean') {
if (typeof newValue === 'boolean') {
// Both exist, trust the new one
if (removeDeprecated) {
delete settings[oldKey];
modified = true;
}
} else {
// Only old exists, migrate to new (inverted)
settings[newKey] = !oldValue;
if (removeDeprecated) {
delete settings[oldKey];
}
modified = true;
}
}
return modified;
};
const processScope = (scope: LoadableSettingScope) => { const processScope = (scope: LoadableSettingScope) => {
const settings = loadedSettings.forScope(scope).settings; const settings = loadedSettings.forScope(scope).settings;
// Migrate inverted boolean settings (disableX -> enableX) // Migrate general settings
// These settings were renamed and their boolean logic inverted
const generalSettings = settings.general as const generalSettings = settings.general as
| Record<string, unknown> | Record<string, unknown>
| undefined; | undefined;
const uiSettings = settings.ui as Record<string, unknown> | undefined;
const contextSettings = settings.context as
| Record<string, unknown>
| undefined;
// Migrate general settings (disableAutoUpdate, disableUpdateNag)
if (generalSettings) { if (generalSettings) {
const newGeneral: Record<string, unknown> = { ...generalSettings }; const newGeneral = { ...generalSettings };
let modified = false; let modified = false;
if (typeof newGeneral['disableAutoUpdate'] === 'boolean') { modified =
if (typeof newGeneral['enableAutoUpdate'] === 'boolean') { migrateBoolean(newGeneral, 'disableAutoUpdate', 'enableAutoUpdate') ||
// Both exist, trust the new one modified;
if (removeDeprecated) { modified =
delete newGeneral['disableAutoUpdate']; migrateBoolean(
modified = true; newGeneral,
} 'disableUpdateNag',
} else { 'enableAutoUpdateNotification',
const oldValue = newGeneral['disableAutoUpdate']; ) || modified;
newGeneral['enableAutoUpdate'] = !oldValue;
if (removeDeprecated) {
delete newGeneral['disableAutoUpdate'];
}
modified = true;
}
}
if (typeof newGeneral['disableUpdateNag'] === 'boolean') {
if (typeof newGeneral['enableAutoUpdateNotification'] === 'boolean') {
// Both exist, trust the new one
if (removeDeprecated) {
delete newGeneral['disableUpdateNag'];
modified = true;
}
} else {
const oldValue = newGeneral['disableUpdateNag'];
newGeneral['enableAutoUpdateNotification'] = !oldValue;
if (removeDeprecated) {
delete newGeneral['disableUpdateNag'];
}
modified = true;
}
}
if (modified) { if (modified) {
loadedSettings.setValue(scope, 'general', newGeneral); loadedSettings.setValue(scope, 'general', newGeneral);
@@ -731,94 +767,63 @@ export function migrateDeprecatedSettings(
} }
// Migrate ui settings // Migrate ui settings
const uiSettings = settings.ui as Record<string, unknown> | undefined;
if (uiSettings) { if (uiSettings) {
const newUi: Record<string, unknown> = { ...uiSettings }; const newUi = { ...uiSettings };
let modified = false;
// Migrate ui.accessibility.disableLoadingPhrases -> ui.accessibility.enableLoadingPhrases
const accessibilitySettings = newUi['accessibility'] as const accessibilitySettings = newUi['accessibility'] as
| Record<string, unknown> | Record<string, unknown>
| undefined; | undefined;
if (
accessibilitySettings &&
typeof accessibilitySettings['disableLoadingPhrases'] === 'boolean'
) {
const newAccessibility: Record<string, unknown> = {
...accessibilitySettings,
};
if (
typeof accessibilitySettings['enableLoadingPhrases'] === 'boolean'
) {
// Both exist, trust the new one
if (removeDeprecated) {
delete newAccessibility['disableLoadingPhrases'];
newUi['accessibility'] = newAccessibility;
modified = true;
}
} else {
const oldValue = accessibilitySettings['disableLoadingPhrases'];
newAccessibility['enableLoadingPhrases'] = !oldValue;
if (removeDeprecated) {
delete newAccessibility['disableLoadingPhrases'];
}
newUi['accessibility'] = newAccessibility;
modified = true;
}
}
if (modified) { if (accessibilitySettings) {
loadedSettings.setValue(scope, 'ui', newUi); const newAccessibility = { ...accessibilitySettings };
anyModified = true; if (
migrateBoolean(
newAccessibility,
'disableLoadingPhrases',
'enableLoadingPhrases',
)
) {
newUi['accessibility'] = newAccessibility;
loadedSettings.setValue(scope, 'ui', newUi);
anyModified = true;
}
} }
} }
// Migrate context settings // Migrate context settings
const contextSettings = settings.context as
| Record<string, unknown>
| undefined;
if (contextSettings) { if (contextSettings) {
const newContext: Record<string, unknown> = { ...contextSettings }; const newContext = { ...contextSettings };
let modified = false;
// Migrate context.fileFiltering.disableFuzzySearch -> context.fileFiltering.enableFuzzySearch
const fileFilteringSettings = newContext['fileFiltering'] as const fileFilteringSettings = newContext['fileFiltering'] as
| Record<string, unknown> | Record<string, unknown>
| undefined; | undefined;
if (
fileFilteringSettings &&
typeof fileFilteringSettings['disableFuzzySearch'] === 'boolean'
) {
const newFileFiltering: Record<string, unknown> = {
...fileFilteringSettings,
};
if (typeof fileFilteringSettings['enableFuzzySearch'] === 'boolean') {
// Both exist, trust the new one
if (removeDeprecated) {
delete newFileFiltering['disableFuzzySearch'];
newContext['fileFiltering'] = newFileFiltering;
modified = true;
}
} else {
const oldValue = fileFilteringSettings['disableFuzzySearch'];
newFileFiltering['enableFuzzySearch'] = !oldValue;
if (removeDeprecated) {
delete newFileFiltering['disableFuzzySearch'];
}
newContext['fileFiltering'] = newFileFiltering;
modified = true;
}
}
if (modified) { if (fileFilteringSettings) {
loadedSettings.setValue(scope, 'context', newContext); const newFileFiltering = { ...fileFilteringSettings };
anyModified = true; if (
migrateBoolean(
newFileFiltering,
'disableFuzzySearch',
'enableFuzzySearch',
)
) {
newContext['fileFiltering'] = newFileFiltering;
loadedSettings.setValue(scope, 'context', newContext);
anyModified = true;
}
} }
} }
// Migrate experimental agent settings // Migrate experimental agent settings
anyModified ||= migrateExperimentalSettings( anyModified =
settings, migrateExperimentalSettings(
loadedSettings, settings,
scope, loadedSettings,
removeDeprecated, scope,
); removeDeprecated,
) || anyModified;
}; };
processScope(SettingScope.User); processScope(SettingScope.User);
+324 -66
View File
@@ -53,6 +53,7 @@ vi.mock('fs', async (importOriginal) => {
readFileSync: vi.fn(), readFileSync: vi.fn(),
writeFileSync: vi.fn(), writeFileSync: vi.fn(),
mkdirSync: vi.fn(), mkdirSync: vi.fn(),
realpathSync: vi.fn((p) => p),
}; };
}); });
vi.mock('strip-json-comments', () => ({ vi.mock('strip-json-comments', () => ({
@@ -60,22 +61,23 @@ vi.mock('strip-json-comments', () => ({
})); }));
describe('Trusted Folders Loading', () => { describe('Trusted Folders Loading', () => {
let mockFsExistsSync: Mocked<typeof fs.existsSync>;
let mockStripJsonComments: Mocked<typeof stripJsonComments>; let mockStripJsonComments: Mocked<typeof stripJsonComments>;
let mockFsWriteFileSync: Mocked<typeof fs.writeFileSync>; let mockFsWriteFileSync: Mocked<typeof fs.writeFileSync>;
beforeEach(() => { beforeEach(() => {
resetTrustedFoldersForTesting(); resetTrustedFoldersForTesting();
vi.resetAllMocks(); vi.resetAllMocks();
mockFsExistsSync = vi.mocked(fs.existsSync);
mockStripJsonComments = vi.mocked(stripJsonComments); mockStripJsonComments = vi.mocked(stripJsonComments);
mockFsWriteFileSync = vi.mocked(fs.writeFileSync); mockFsWriteFileSync = vi.mocked(fs.writeFileSync);
vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user'); vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user');
(mockStripJsonComments as unknown as Mock).mockImplementation( (mockStripJsonComments as unknown as Mock).mockImplementation(
(jsonString: string) => jsonString, (jsonString: string) => jsonString,
); );
(mockFsExistsSync as Mock).mockReturnValue(false); vi.mocked(fs.existsSync).mockReturnValue(false);
(fs.readFileSync as Mock).mockReturnValue('{}'); vi.mocked(fs.readFileSync).mockReturnValue('{}');
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) =>
p.toString(),
);
}); });
afterEach(() => { afterEach(() => {
@@ -90,13 +92,16 @@ describe('Trusted Folders Loading', () => {
describe('isPathTrusted', () => { describe('isPathTrusted', () => {
function setup({ config = {} as Record<string, TrustLevel> } = {}) { function setup({ config = {} as Record<string, TrustLevel> } = {}) {
(mockFsExistsSync as Mock).mockImplementation( vi.mocked(fs.existsSync).mockImplementation(
(p) => p === getTrustedFoldersPath(), (p: fs.PathLike) => p.toString() === getTrustedFoldersPath(),
);
vi.mocked(fs.readFileSync).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === getTrustedFoldersPath())
return JSON.stringify(config);
return '{}';
},
); );
(fs.readFileSync as Mock).mockImplementation((p) => {
if (p === getTrustedFoldersPath()) return JSON.stringify(config);
return '{}';
});
const folders = loadTrustedFolders(); const folders = loadTrustedFolders();
@@ -124,26 +129,62 @@ describe('Trusted Folders Loading', () => {
expect(folders.isPathTrusted('/trustedparent/trustme')).toBe(true); expect(folders.isPathTrusted('/trustedparent/trustme')).toBe(true);
// No explicit rule covers this file // No explicit rule covers this file
expect(folders.isPathTrusted('/secret/bankaccounts.json')).toBe( expect(folders.isPathTrusted('/secret/bankaccounts.json')).toBe(false);
undefined, expect(folders.isPathTrusted('/secret/mine/privatekey.pem')).toBe(false);
);
expect(folders.isPathTrusted('/secret/mine/privatekey.pem')).toBe(
undefined,
);
expect(folders.isPathTrusted('/user/someotherfolder')).toBe(undefined); expect(folders.isPathTrusted('/user/someotherfolder')).toBe(undefined);
}); });
it('prioritizes the longest matching path (precedence)', () => {
const { folders } = setup({
config: {
'/a': TrustLevel.TRUST_FOLDER,
'/a/b': TrustLevel.DO_NOT_TRUST,
'/a/b/c': TrustLevel.TRUST_FOLDER,
'/parent/trustme': TrustLevel.TRUST_PARENT, // effective path is /parent
'/parent/trustme/butnotthis': TrustLevel.DO_NOT_TRUST,
},
});
// /a/b/c/d matches /a (len 2), /a/b (len 4), /a/b/c (len 6).
// /a/b/c wins (TRUST_FOLDER).
expect(folders.isPathTrusted('/a/b/c/d')).toBe(true);
// /a/b/x matches /a (len 2), /a/b (len 4).
// /a/b wins (DO_NOT_TRUST).
expect(folders.isPathTrusted('/a/b/x')).toBe(false);
// /a/x matches /a (len 2).
// /a wins (TRUST_FOLDER).
expect(folders.isPathTrusted('/a/x')).toBe(true);
// Overlap with TRUST_PARENT
// /parent/trustme/butnotthis/file matches:
// - /parent/trustme (len 15, TRUST_PARENT -> effective /parent)
// - /parent/trustme/butnotthis (len 26, DO_NOT_TRUST)
// /parent/trustme/butnotthis wins.
expect(folders.isPathTrusted('/parent/trustme/butnotthis/file')).toBe(
false,
);
// /parent/other matches /parent/trustme (len 15, effective /parent)
expect(folders.isPathTrusted('/parent/other')).toBe(true);
});
}); });
it('should load user rules if only user file exists', () => { it('should load user rules if only user file exists', () => {
const userPath = getTrustedFoldersPath(); const userPath = getTrustedFoldersPath();
(mockFsExistsSync as Mock).mockImplementation((p) => p === userPath); vi.mocked(fs.existsSync).mockImplementation(
(p: fs.PathLike) => p.toString() === userPath,
);
const userContent = { const userContent = {
'/user/folder': TrustLevel.TRUST_FOLDER, '/user/folder': TrustLevel.TRUST_FOLDER,
}; };
(fs.readFileSync as Mock).mockImplementation((p) => { vi.mocked(fs.readFileSync).mockImplementation(
if (p === userPath) return JSON.stringify(userContent); (p: fs.PathOrFileDescriptor) => {
return '{}'; if (p.toString() === userPath) return JSON.stringify(userContent);
}); return '{}';
},
);
const { rules, errors } = loadTrustedFolders(); const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([ expect(rules).toEqual([
@@ -154,11 +195,15 @@ describe('Trusted Folders Loading', () => {
it('should handle JSON parsing errors gracefully', () => { it('should handle JSON parsing errors gracefully', () => {
const userPath = getTrustedFoldersPath(); const userPath = getTrustedFoldersPath();
(mockFsExistsSync as Mock).mockImplementation((p) => p === userPath); vi.mocked(fs.existsSync).mockImplementation(
(fs.readFileSync as Mock).mockImplementation((p) => { (p: fs.PathLike) => p.toString() === userPath,
if (p === userPath) return 'invalid json'; );
return '{}'; vi.mocked(fs.readFileSync).mockImplementation(
}); (p: fs.PathOrFileDescriptor) => {
if (p.toString() === userPath) return 'invalid json';
return '{}';
},
);
const { rules, errors } = loadTrustedFolders(); const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([]); expect(rules).toEqual([]);
@@ -171,14 +216,18 @@ describe('Trusted Folders Loading', () => {
const customPath = '/custom/path/to/trusted_folders.json'; const customPath = '/custom/path/to/trusted_folders.json';
process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'] = customPath; process.env['GEMINI_CLI_TRUSTED_FOLDERS_PATH'] = customPath;
(mockFsExistsSync as Mock).mockImplementation((p) => p === customPath); vi.mocked(fs.existsSync).mockImplementation(
(p: fs.PathLike) => p.toString() === customPath,
);
const userContent = { const userContent = {
'/user/folder/from/env': TrustLevel.TRUST_FOLDER, '/user/folder/from/env': TrustLevel.TRUST_FOLDER,
}; };
(fs.readFileSync as Mock).mockImplementation((p) => { vi.mocked(fs.readFileSync).mockImplementation(
if (p === customPath) return JSON.stringify(userContent); (p: fs.PathOrFileDescriptor) => {
return '{}'; if (p.toString() === customPath) return JSON.stringify(userContent);
}); return '{}';
},
);
const { rules, errors } = loadTrustedFolders(); const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([ expect(rules).toEqual([
@@ -221,14 +270,16 @@ describe('isWorkspaceTrusted', () => {
beforeEach(() => { beforeEach(() => {
resetTrustedFoldersForTesting(); resetTrustedFoldersForTesting();
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd); vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => { vi.spyOn(fs, 'readFileSync').mockImplementation(
if (p === getTrustedFoldersPath()) { (p: fs.PathOrFileDescriptor) => {
return JSON.stringify(mockRules); if (p.toString() === getTrustedFoldersPath()) {
} return JSON.stringify(mockRules);
return '{}'; }
}); return '{}';
},
);
vi.spyOn(fs, 'existsSync').mockImplementation( vi.spyOn(fs, 'existsSync').mockImplementation(
(p) => p === getTrustedFoldersPath(), (p: fs.PathLike) => p.toString() === getTrustedFoldersPath(),
); );
}); });
@@ -241,12 +292,14 @@ describe('isWorkspaceTrusted', () => {
it('should throw a fatal error if the config is malformed', () => { it('should throw a fatal error if the config is malformed', () => {
mockCwd = '/home/user/projectA'; mockCwd = '/home/user/projectA';
// This mock needs to be specific to this test to override the one in beforeEach // This mock needs to be specific to this test to override the one in beforeEach
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => { vi.spyOn(fs, 'readFileSync').mockImplementation(
if (p === getTrustedFoldersPath()) { (p: fs.PathOrFileDescriptor) => {
return '{"foo": "bar",}'; // Malformed JSON with trailing comma if (p.toString() === getTrustedFoldersPath()) {
} return '{"foo": "bar",}'; // Malformed JSON with trailing comma
return '{}'; }
}); return '{}';
},
);
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError); expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError);
expect(() => isWorkspaceTrusted(mockSettings)).toThrow( expect(() => isWorkspaceTrusted(mockSettings)).toThrow(
/Please fix the configuration file/, /Please fix the configuration file/,
@@ -255,12 +308,14 @@ describe('isWorkspaceTrusted', () => {
it('should throw a fatal error if the config is not a JSON object', () => { it('should throw a fatal error if the config is not a JSON object', () => {
mockCwd = '/home/user/projectA'; mockCwd = '/home/user/projectA';
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => { vi.spyOn(fs, 'readFileSync').mockImplementation(
if (p === getTrustedFoldersPath()) { (p: fs.PathOrFileDescriptor) => {
return 'null'; if (p.toString() === getTrustedFoldersPath()) {
} return 'null';
return '{}'; }
}); return '{}';
},
);
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError); expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError);
expect(() => isWorkspaceTrusted(mockSettings)).toThrow( expect(() => isWorkspaceTrusted(mockSettings)).toThrow(
/not a valid JSON object/, /not a valid JSON object/,
@@ -303,10 +358,10 @@ describe('isWorkspaceTrusted', () => {
}); });
}); });
it('should return undefined for a child of an untrusted folder', () => { it('should return false for a child of an untrusted folder', () => {
mockCwd = '/home/user/untrusted/src'; mockCwd = '/home/user/untrusted/src';
mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST; mockRules['/home/user/untrusted'] = TrustLevel.DO_NOT_TRUST;
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined(); expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(false);
}); });
it('should return undefined when no rules match', () => { it('should return undefined when no rules match', () => {
@@ -316,12 +371,12 @@ describe('isWorkspaceTrusted', () => {
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined(); expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined();
}); });
it('should prioritize trust over distrust', () => { it('should prioritize specific distrust over parent trust', () => {
mockCwd = '/home/user/projectA/untrusted'; mockCwd = '/home/user/projectA/untrusted';
mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER; mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER;
mockRules['/home/user/projectA/untrusted'] = TrustLevel.DO_NOT_TRUST; mockRules['/home/user/projectA/untrusted'] = TrustLevel.DO_NOT_TRUST;
expect(isWorkspaceTrusted(mockSettings)).toEqual({ expect(isWorkspaceTrusted(mockSettings)).toEqual({
isTrusted: true, isTrusted: false,
source: 'file', source: 'file',
}); });
}); });
@@ -351,6 +406,19 @@ describe('isWorkspaceTrusted', () => {
}); });
describe('isWorkspaceTrusted with IDE override', () => { describe('isWorkspaceTrusted with IDE override', () => {
const mockCwd = '/home/user/projectA';
beforeEach(() => {
resetTrustedFoldersForTesting();
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
p.toString(),
);
vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) =>
p.toString().endsWith('trustedFolders.json') ? false : true,
);
});
afterEach(() => { afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
ideContextStore.clear(); ideContextStore.clear();
@@ -390,10 +458,15 @@ describe('isWorkspaceTrusted with IDE override', () => {
}); });
it('should fall back to config when ideTrust is undefined', () => { it('should fall back to config when ideTrust is undefined', () => {
vi.spyOn(fs, 'existsSync').mockReturnValue(true); vi.spyOn(fs, 'existsSync').mockImplementation((p) =>
vi.spyOn(fs, 'readFileSync').mockReturnValue( p === getTrustedFoldersPath() || p === mockCwd ? true : false,
JSON.stringify({ [process.cwd()]: TrustLevel.TRUST_FOLDER }),
); );
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
if (p === getTrustedFoldersPath()) {
return JSON.stringify({ [mockCwd]: TrustLevel.TRUST_FOLDER });
}
return '{}';
});
expect(isWorkspaceTrusted(mockSettings)).toEqual({ expect(isWorkspaceTrusted(mockSettings)).toEqual({
isTrusted: true, isTrusted: true,
source: 'file', source: 'file',
@@ -419,8 +492,11 @@ describe('isWorkspaceTrusted with IDE override', () => {
describe('Trusted Folders Caching', () => { describe('Trusted Folders Caching', () => {
beforeEach(() => { beforeEach(() => {
resetTrustedFoldersForTesting(); resetTrustedFoldersForTesting();
vi.mocked(fs.existsSync).mockReturnValue(true); vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('{}'); vi.spyOn(fs, 'readFileSync').mockReturnValue('{}');
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
p.toString(),
);
}); });
afterEach(() => { afterEach(() => {
@@ -454,14 +530,20 @@ describe('invalid trust levels', () => {
beforeEach(() => { beforeEach(() => {
resetTrustedFoldersForTesting(); resetTrustedFoldersForTesting();
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd); vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => { vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
if (p === getTrustedFoldersPath()) { p.toString(),
return JSON.stringify(mockRules); );
} vi.spyOn(fs, 'readFileSync').mockImplementation(
return '{}'; (p: fs.PathOrFileDescriptor) => {
}); if (p.toString() === getTrustedFoldersPath()) {
return JSON.stringify(mockRules);
}
return '{}';
},
);
vi.spyOn(fs, 'existsSync').mockImplementation( vi.spyOn(fs, 'existsSync').mockImplementation(
(p) => p === getTrustedFoldersPath(), (p: fs.PathLike) =>
p.toString() === getTrustedFoldersPath() || p.toString() === mockCwd,
); );
}); });
@@ -495,3 +577,179 @@ describe('invalid trust levels', () => {
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError); expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError);
}); });
}); });
describe('Trusted Folders realpath caching', () => {
beforeEach(() => {
resetTrustedFoldersForTesting();
vi.resetAllMocks();
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
p.toString(),
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should only call fs.realpathSync once for the same path', () => {
const mockPath = '/some/path';
const mockRealPath = '/real/path';
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
const realpathSpy = vi
.spyOn(fs, 'realpathSync')
.mockReturnValue(mockRealPath);
vi.spyOn(fs, 'readFileSync').mockReturnValue(
JSON.stringify({
[mockPath]: TrustLevel.TRUST_FOLDER,
'/another/path': TrustLevel.TRUST_FOLDER,
}),
);
const folders = loadTrustedFolders();
// Call isPathTrusted multiple times with the same path
folders.isPathTrusted(mockPath);
folders.isPathTrusted(mockPath);
folders.isPathTrusted(mockPath);
// fs.realpathSync should only be called once for mockPath (at the start of isPathTrusted)
// And once for each rule in the config (if they are different)
// Let's check calls for mockPath
const mockPathCalls = realpathSpy.mock.calls.filter(
(call) => call[0] === mockPath,
);
expect(mockPathCalls.length).toBe(1);
});
it('should cache results for rule paths in the loop', () => {
const rulePath = '/rule/path';
const locationPath = '/location/path';
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
const realpathSpy = vi
.spyOn(fs, 'realpathSync')
.mockImplementation((p: fs.PathLike) => p.toString()); // identity for simplicity
vi.spyOn(fs, 'readFileSync').mockReturnValue(
JSON.stringify({
[rulePath]: TrustLevel.TRUST_FOLDER,
}),
);
const folders = loadTrustedFolders();
// First call
folders.isPathTrusted(locationPath);
const firstCallCount = realpathSpy.mock.calls.length;
expect(firstCallCount).toBe(2); // locationPath and rulePath
// Second call with same location and same config
folders.isPathTrusted(locationPath);
const secondCallCount = realpathSpy.mock.calls.length;
// Should still be 2 because both were cached
expect(secondCallCount).toBe(2);
});
});
describe('isWorkspaceTrusted with Symlinks', () => {
const mockSettings: Settings = {
security: {
folderTrust: {
enabled: true,
},
},
};
beforeEach(() => {
resetTrustedFoldersForTesting();
vi.resetAllMocks();
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
p.toString(),
);
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should trust a folder even if CWD is a symlink and rule is realpath', () => {
const symlinkPath = '/var/folders/project';
const realPath = '/private/var/folders/project';
vi.spyOn(process, 'cwd').mockReturnValue(symlinkPath);
// Mock fs.existsSync to return true for trust config and both paths
vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) => {
const pathStr = p.toString();
if (pathStr === getTrustedFoldersPath()) return true;
if (pathStr === symlinkPath) return true;
if (pathStr === realPath) return true;
return false;
});
// Mock realpathSync to resolve symlink to realpath
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => {
const pathStr = p.toString();
if (pathStr === symlinkPath) return realPath;
if (pathStr === realPath) return realPath;
return pathStr;
});
// Rule is saved with realpath
const mockRules = {
[realPath]: TrustLevel.TRUST_FOLDER,
};
vi.spyOn(fs, 'readFileSync').mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === getTrustedFoldersPath())
return JSON.stringify(mockRules);
return '{}';
},
);
// Should be trusted because both resolve to the same realpath
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(true);
});
it('should trust a folder even if CWD is realpath and rule is a symlink', () => {
const symlinkPath = '/var/folders/project';
const realPath = '/private/var/folders/project';
vi.spyOn(process, 'cwd').mockReturnValue(realPath);
// Mock fs.existsSync
vi.spyOn(fs, 'existsSync').mockImplementation((p: fs.PathLike) => {
const pathStr = p.toString();
if (pathStr === getTrustedFoldersPath()) return true;
if (pathStr === symlinkPath) return true;
if (pathStr === realPath) return true;
return false;
});
// Mock realpathSync
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) => {
const pathStr = p.toString();
if (pathStr === symlinkPath) return realPath;
if (pathStr === realPath) return realPath;
return pathStr;
});
// Rule is saved with symlink path
const mockRules = {
[symlinkPath]: TrustLevel.TRUST_FOLDER,
};
vi.spyOn(fs, 'readFileSync').mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === getTrustedFoldersPath())
return JSON.stringify(mockRules);
return '{}';
},
);
// Should be trusted because both resolve to the same realpath
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBe(true);
});
});
+61 -33
View File
@@ -36,7 +36,9 @@ export enum TrustLevel {
DO_NOT_TRUST = 'DO_NOT_TRUST', DO_NOT_TRUST = 'DO_NOT_TRUST',
} }
export function isTrustLevel(value: unknown): value is TrustLevel { export function isTrustLevel(
value: string | number | boolean | object | null | undefined,
): value is TrustLevel {
return ( return (
typeof value === 'string' && typeof value === 'string' &&
Object.values(TrustLevel).includes(value as TrustLevel) Object.values(TrustLevel).includes(value as TrustLevel)
@@ -63,6 +65,32 @@ export interface TrustResult {
source: 'ide' | 'file' | undefined; source: 'ide' | 'file' | undefined;
} }
const realPathCache = new Map<string, string>();
/**
* FOR TESTING PURPOSES ONLY.
* Clears the real path cache.
*/
export function clearRealPathCacheForTesting(): void {
realPathCache.clear();
}
function getRealPath(location: string): string {
let realPath = realPathCache.get(location);
if (realPath !== undefined) {
return realPath;
}
try {
realPath = fs.existsSync(location) ? fs.realpathSync(location) : location;
} catch {
realPath = location;
}
realPathCache.set(location, realPath);
return realPath;
}
export class LoadedTrustedFolders { export class LoadedTrustedFolders {
constructor( constructor(
readonly user: TrustedFoldersFile, readonly user: TrustedFoldersFile,
@@ -88,39 +116,36 @@ export class LoadedTrustedFolders {
config?: Record<string, TrustLevel>, config?: Record<string, TrustLevel>,
): boolean | undefined { ): boolean | undefined {
const configToUse = config ?? this.user.config; const configToUse = config ?? this.user.config;
const trustedPaths: string[] = [];
const untrustedPaths: string[] = [];
for (const rule of Object.entries(configToUse).map( // Resolve location to its realpath for canonical comparison
([path, trustLevel]) => ({ path, trustLevel }), const realLocation = getRealPath(location);
)) {
switch (rule.trustLevel) { let longestMatchLen = -1;
case TrustLevel.TRUST_FOLDER: let longestMatchTrust: TrustLevel | undefined = undefined;
trustedPaths.push(rule.path);
break; for (const [rulePath, trustLevel] of Object.entries(configToUse)) {
case TrustLevel.TRUST_PARENT: const effectivePath =
trustedPaths.push(path.dirname(rule.path)); trustLevel === TrustLevel.TRUST_PARENT
break; ? path.dirname(rulePath)
case TrustLevel.DO_NOT_TRUST: : rulePath;
untrustedPaths.push(rule.path);
break; // Resolve effectivePath to its realpath for canonical comparison
default: const realEffectivePath = getRealPath(effectivePath);
// Do nothing for unknown trust levels.
break; if (isWithinRoot(realLocation, realEffectivePath)) {
if (rulePath.length > longestMatchLen) {
longestMatchLen = rulePath.length;
longestMatchTrust = trustLevel;
}
} }
} }
for (const trustedPath of trustedPaths) { if (longestMatchTrust === TrustLevel.DO_NOT_TRUST) return false;
if (isWithinRoot(location, trustedPath)) { if (
return true; longestMatchTrust === TrustLevel.TRUST_FOLDER ||
} longestMatchTrust === TrustLevel.TRUST_PARENT
} )
return true;
for (const untrustedPath of untrustedPaths) {
if (path.normalize(location) === path.normalize(untrustedPath)) {
return false;
}
}
return undefined; return undefined;
} }
@@ -150,6 +175,7 @@ let loadedTrustedFolders: LoadedTrustedFolders | undefined;
*/ */
export function resetTrustedFoldersForTesting(): void { export function resetTrustedFoldersForTesting(): void {
loadedTrustedFolders = undefined; loadedTrustedFolders = undefined;
clearRealPathCacheForTesting();
} }
export function loadTrustedFolders(): LoadedTrustedFolders { export function loadTrustedFolders(): LoadedTrustedFolders {
@@ -161,11 +187,13 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
const userConfig: Record<string, TrustLevel> = {}; const userConfig: Record<string, TrustLevel> = {};
const userPath = getTrustedFoldersPath(); const userPath = getTrustedFoldersPath();
// Load user trusted folders
try { try {
if (fs.existsSync(userPath)) { if (fs.existsSync(userPath)) {
const content = fs.readFileSync(userPath, 'utf-8'); const content = fs.readFileSync(userPath, 'utf-8');
const parsed: unknown = JSON.parse(stripJsonComments(content)); const parsed = JSON.parse(stripJsonComments(content)) as Record<
string,
string
>;
if ( if (
typeof parsed !== 'object' || typeof parsed !== 'object' ||
@@ -190,7 +218,7 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
} }
} }
} }
} catch (error: unknown) { } catch (error) {
errors.push({ errors.push({
message: getErrorMessage(error), message: getErrorMessage(error),
path: userPath, path: userPath,
File diff suppressed because it is too large Load Diff
+178
View File
@@ -0,0 +1,178 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
import type { Config } from '@google/gemini-cli-core';
import type { LoadedSettings, Settings } from '../config/settings.js';
import { createTestMergedSettings } from '../config/settings.js';
/**
* Creates a mocked Config object with default values and allows overrides.
*/
export const createMockConfig = (overrides: Partial<Config> = {}): Config =>
({
getSandbox: vi.fn(() => undefined),
getQuestion: vi.fn(() => ''),
isInteractive: vi.fn(() => false),
setTerminalBackground: vi.fn(),
storage: {
getProjectTempDir: vi.fn().mockReturnValue('/tmp/gemini-test'),
},
getDebugMode: vi.fn(() => false),
getProjectRoot: vi.fn(() => '/'),
refreshAuth: vi.fn().mockResolvedValue(undefined),
getRemoteAdminSettings: vi.fn(() => undefined),
initialize: vi.fn().mockResolvedValue(undefined),
getPolicyEngine: vi.fn(() => ({})),
getMessageBus: vi.fn(() => ({ subscribe: vi.fn() })),
getHookSystem: vi.fn(() => ({
fireSessionEndEvent: vi.fn().mockResolvedValue(undefined),
fireSessionStartEvent: vi.fn().mockResolvedValue(undefined),
})),
getListExtensions: vi.fn(() => false),
getExtensions: vi.fn(() => []),
getListSessions: vi.fn(() => false),
getDeleteSession: vi.fn(() => undefined),
setSessionId: vi.fn(),
getSessionId: vi.fn().mockReturnValue('mock-session-id'),
getContentGeneratorConfig: vi.fn(() => ({ authType: 'google' })),
getExperimentalZedIntegration: vi.fn(() => false),
isBrowserLaunchSuppressed: vi.fn(() => false),
setRemoteAdminSettings: vi.fn(),
isYoloModeDisabled: vi.fn(() => false),
isPlanEnabled: vi.fn(() => false),
isEventDrivenSchedulerEnabled: vi.fn(() => false),
getCoreTools: vi.fn(() => []),
getAllowedTools: vi.fn(() => []),
getApprovalMode: vi.fn(() => 'default'),
getFileFilteringRespectGitIgnore: vi.fn(() => true),
getOutputFormat: vi.fn(() => 'text'),
getUsageStatisticsEnabled: vi.fn(() => true),
getScreenReader: vi.fn(() => false),
getGeminiMdFileCount: vi.fn(() => 0),
getDeferredCommand: vi.fn(() => undefined),
getFileSystemService: vi.fn(() => ({})),
clientVersion: '1.0.0',
getModel: vi.fn().mockReturnValue('gemini-pro'),
getWorkingDir: vi.fn().mockReturnValue('/mock/cwd'),
getToolRegistry: vi.fn().mockReturnValue({
getTools: vi.fn().mockReturnValue([]),
getAllTools: vi.fn().mockReturnValue([]),
}),
getAgentRegistry: vi.fn().mockReturnValue({}),
getPromptRegistry: vi.fn().mockReturnValue({}),
getResourceRegistry: vi.fn().mockReturnValue({}),
getSkillManager: vi.fn().mockReturnValue({
isAdminEnabled: vi.fn().mockReturnValue(false),
}),
getFileService: vi.fn().mockReturnValue({}),
getGitService: vi.fn().mockResolvedValue({}),
getUserMemory: vi.fn().mockReturnValue(''),
getGeminiMdFilePaths: vi.fn().mockReturnValue([]),
getShowMemoryUsage: vi.fn().mockReturnValue(false),
getAccessibility: vi.fn().mockReturnValue({}),
getTelemetryEnabled: vi.fn().mockReturnValue(false),
getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false),
getTelemetryOtlpEndpoint: vi.fn().mockReturnValue(''),
getTelemetryOtlpProtocol: vi.fn().mockReturnValue('grpc'),
getTelemetryTarget: vi.fn().mockReturnValue(''),
getTelemetryOutfile: vi.fn().mockReturnValue(undefined),
getTelemetryUseCollector: vi.fn().mockReturnValue(false),
getTelemetryUseCliAuth: vi.fn().mockReturnValue(false),
getGeminiClient: vi.fn().mockReturnValue({
isInitialized: vi.fn().mockReturnValue(true),
}),
updateSystemInstructionIfInitialized: vi.fn().mockResolvedValue(undefined),
getModelRouterService: vi.fn().mockReturnValue({}),
getModelAvailabilityService: vi.fn().mockReturnValue({}),
getEnableRecursiveFileSearch: vi.fn().mockReturnValue(true),
getFileFilteringEnableFuzzySearch: vi.fn().mockReturnValue(true),
getFileFilteringRespectGeminiIgnore: vi.fn().mockReturnValue(true),
getFileFilteringOptions: vi.fn().mockReturnValue({}),
getCustomExcludes: vi.fn().mockReturnValue([]),
getCheckpointingEnabled: vi.fn().mockReturnValue(false),
getProxy: vi.fn().mockReturnValue(undefined),
getBugCommand: vi.fn().mockReturnValue(undefined),
getExtensionManagement: vi.fn().mockReturnValue(true),
getExtensionLoader: vi.fn().mockReturnValue({}),
getEnabledExtensions: vi.fn().mockReturnValue([]),
getEnableExtensionReloading: vi.fn().mockReturnValue(false),
getDisableLLMCorrection: vi.fn().mockReturnValue(false),
getNoBrowser: vi.fn().mockReturnValue(false),
getAgentsSettings: vi.fn().mockReturnValue({}),
getSummarizeToolOutputConfig: vi.fn().mockReturnValue(undefined),
getIdeMode: vi.fn().mockReturnValue(false),
getFolderTrust: vi.fn().mockReturnValue(true),
isTrustedFolder: vi.fn().mockReturnValue(true),
getCompressionThreshold: vi.fn().mockResolvedValue(undefined),
getUserCaching: vi.fn().mockResolvedValue(false),
getNumericalRoutingEnabled: vi.fn().mockResolvedValue(false),
getClassifierThreshold: vi.fn().mockResolvedValue(undefined),
getBannerTextNoCapacityIssues: vi.fn().mockResolvedValue(''),
getBannerTextCapacityIssues: vi.fn().mockResolvedValue(''),
isInteractiveShellEnabled: vi.fn().mockReturnValue(false),
isSkillsSupportEnabled: vi.fn().mockReturnValue(false),
reloadSkills: vi.fn().mockResolvedValue(undefined),
reloadAgents: vi.fn().mockResolvedValue(undefined),
getUseRipgrep: vi.fn().mockReturnValue(false),
getEnableInteractiveShell: vi.fn().mockReturnValue(false),
getSkipNextSpeakerCheck: vi.fn().mockReturnValue(false),
getContinueOnFailedApiCall: vi.fn().mockReturnValue(false),
getRetryFetchErrors: vi.fn().mockReturnValue(false),
getEnableShellOutputEfficiency: vi.fn().mockReturnValue(true),
getShellToolInactivityTimeout: vi.fn().mockReturnValue(300000),
getShellExecutionConfig: vi.fn().mockReturnValue({}),
setShellExecutionConfig: vi.fn(),
getEnablePromptCompletion: vi.fn().mockReturnValue(false),
getEnableToolOutputTruncation: vi.fn().mockReturnValue(true),
getTruncateToolOutputThreshold: vi.fn().mockReturnValue(1000),
getTruncateToolOutputLines: vi.fn().mockReturnValue(100),
getNextCompressionTruncationId: vi.fn().mockReturnValue(1),
getUseWriteTodos: vi.fn().mockReturnValue(false),
getFileExclusions: vi.fn().mockReturnValue({}),
getEnableHooks: vi.fn().mockReturnValue(true),
getEnableHooksUI: vi.fn().mockReturnValue(true),
getMcpClientManager: vi.fn().mockReturnValue({
getMcpInstructions: vi.fn().mockReturnValue(''),
getMcpServers: vi.fn().mockReturnValue({}),
}),
getEnableEventDrivenScheduler: vi.fn().mockReturnValue(false),
getAdminSkillsEnabled: vi.fn().mockReturnValue(false),
getDisabledSkills: vi.fn().mockReturnValue([]),
getExperimentalJitContext: vi.fn().mockReturnValue(false),
getTerminalBackground: vi.fn().mockReturnValue(undefined),
getEmbeddingModel: vi.fn().mockReturnValue('embedding-model'),
getQuotaErrorOccurred: vi.fn().mockReturnValue(false),
getMaxSessionTurns: vi.fn().mockReturnValue(100),
getExcludeTools: vi.fn().mockReturnValue(new Set()),
getAllowedMcpServers: vi.fn().mockReturnValue([]),
getBlockedMcpServers: vi.fn().mockReturnValue([]),
getExperiments: vi.fn().mockReturnValue(undefined),
getPreviewFeatures: vi.fn().mockReturnValue(false),
getHasAccessToPreviewModel: vi.fn().mockReturnValue(false),
...overrides,
}) as unknown as Config;
/**
* Creates a mocked LoadedSettings object for tests.
*/
export function createMockSettings(
overrides: Record<string, unknown> = {},
): LoadedSettings {
const merged = createTestMergedSettings(
(overrides['merged'] as Partial<Settings>) || {},
);
return {
system: { settings: {} },
systemDefaults: { settings: {} },
user: { settings: {} },
workspace: { settings: {} },
errors: [],
...overrides,
merged,
} as unknown as LoadedSettings;
}
+1 -1
View File
@@ -224,7 +224,7 @@ export const AppContainer = (props: AppContainerProps) => {
const activeHooks = useHookDisplayState(); const activeHooks = useHookDisplayState();
const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null); const [updateInfo, setUpdateInfo] = useState<UpdateObject | null>(null);
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>( const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
isWorkspaceTrusted(settings.merged).isTrusted, () => isWorkspaceTrusted(settings.merged).isTrusted,
); );
const [queueErrorMessage, setQueueErrorMessage] = useState<string | null>( const [queueErrorMessage, setQueueErrorMessage] = useState<string | null>(
@@ -32,11 +32,12 @@ vi.mock('node:process', async () => {
describe('FolderTrustDialog', () => { describe('FolderTrustDialog', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.useRealTimers();
mockedCwd.mockReturnValue('/home/user/project'); mockedCwd.mockReturnValue('/home/user/project');
}); });
it('should render the dialog with title and description', () => { it('should render the dialog with title and description', () => {
const { lastFrame } = renderWithProviders( const { lastFrame, unmount } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />, <FolderTrustDialog onSelect={vi.fn()} />,
); );
@@ -44,11 +45,12 @@ describe('FolderTrustDialog', () => {
expect(lastFrame()).toContain( expect(lastFrame()).toContain(
'Trusting a folder allows Gemini to execute commands it suggests.', 'Trusting a folder allows Gemini to execute commands it suggests.',
); );
unmount();
}); });
it('should display exit message and call process.exit and not call onSelect when escape is pressed', async () => { it('should display exit message and call process.exit and not call onSelect when escape is pressed', async () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const { lastFrame, stdin } = renderWithProviders( const { lastFrame, stdin, unmount } = renderWithProviders(
<FolderTrustDialog onSelect={onSelect} isRestarting={false} />, <FolderTrustDialog onSelect={onSelect} isRestarting={false} />,
); );
@@ -67,24 +69,27 @@ describe('FolderTrustDialog', () => {
); );
}); });
expect(onSelect).not.toHaveBeenCalled(); expect(onSelect).not.toHaveBeenCalled();
unmount();
}); });
it('should display restart message when isRestarting is true', () => { it('should display restart message when isRestarting is true', () => {
const { lastFrame } = renderWithProviders( const { lastFrame, unmount } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />, <FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
); );
expect(lastFrame()).toContain('Gemini CLI is restarting'); expect(lastFrame()).toContain('Gemini CLI is restarting');
unmount();
}); });
it('should call relaunchApp when isRestarting is true', async () => { it('should call relaunchApp when isRestarting is true', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const relaunchApp = vi.spyOn(processUtils, 'relaunchApp'); const relaunchApp = vi.spyOn(processUtils, 'relaunchApp');
renderWithProviders( const { unmount } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />, <FolderTrustDialog onSelect={vi.fn()} isRestarting={true} />,
); );
await vi.advanceTimersByTimeAsync(250); await vi.advanceTimersByTimeAsync(250);
expect(relaunchApp).toHaveBeenCalled(); expect(relaunchApp).toHaveBeenCalled();
unmount();
vi.useRealTimers(); vi.useRealTimers();
}); });
@@ -106,7 +111,7 @@ describe('FolderTrustDialog', () => {
}); });
it('should not call process.exit when "r" is pressed and isRestarting is false', async () => { it('should not call process.exit when "r" is pressed and isRestarting is false', async () => {
const { stdin } = renderWithProviders( const { stdin, unmount } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} isRestarting={false} />, <FolderTrustDialog onSelect={vi.fn()} isRestarting={false} />,
); );
@@ -117,31 +122,35 @@ describe('FolderTrustDialog', () => {
await waitFor(() => { await waitFor(() => {
expect(mockedExit).not.toHaveBeenCalled(); expect(mockedExit).not.toHaveBeenCalled();
}); });
unmount();
}); });
describe('directory display', () => { describe('directory display', () => {
it('should correctly display the folder name for a nested directory', () => { it('should correctly display the folder name for a nested directory', () => {
mockedCwd.mockReturnValue('/home/user/project'); mockedCwd.mockReturnValue('/home/user/project');
const { lastFrame } = renderWithProviders( const { lastFrame, unmount } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />, <FolderTrustDialog onSelect={vi.fn()} />,
); );
expect(lastFrame()).toContain('Trust folder (project)'); expect(lastFrame()).toContain('Trust folder (project)');
unmount();
}); });
it('should correctly display the parent folder name for a nested directory', () => { it('should correctly display the parent folder name for a nested directory', () => {
mockedCwd.mockReturnValue('/home/user/project'); mockedCwd.mockReturnValue('/home/user/project');
const { lastFrame } = renderWithProviders( const { lastFrame, unmount } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />, <FolderTrustDialog onSelect={vi.fn()} />,
); );
expect(lastFrame()).toContain('Trust parent folder (user)'); expect(lastFrame()).toContain('Trust parent folder (user)');
unmount();
}); });
it('should correctly display an empty parent folder name for a directory directly under root', () => { it('should correctly display an empty parent folder name for a directory directly under root', () => {
mockedCwd.mockReturnValue('/project'); mockedCwd.mockReturnValue('/project');
const { lastFrame } = renderWithProviders( const { lastFrame, unmount } = renderWithProviders(
<FolderTrustDialog onSelect={vi.fn()} />, <FolderTrustDialog onSelect={vi.fn()} />,
); );
expect(lastFrame()).toContain('Trust parent folder ()'); expect(lastFrame()).toContain('Trust parent folder ()');
unmount();
}); });
}); });
}); });
@@ -21,17 +21,6 @@ import {
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { ToolCallStatus } from '../types.js'; import { ToolCallStatus } from '../types.js';
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
debugLogger: {
warn: vi.fn(),
},
};
});
describe('toolMapping', () => { describe('toolMapping', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -74,7 +74,6 @@ export const useThemeCommand = (
const handleThemeSelect = useCallback( const handleThemeSelect = useCallback(
(themeName: string, scope: LoadableSettingScope) => { (themeName: string, scope: LoadableSettingScope) => {
try { try {
// Merge user and workspace custom themes (workspace takes precedence)
const mergedCustomThemes = { const mergedCustomThemes = {
...(loadedSettings.user.settings.ui?.customThemes || {}), ...(loadedSettings.user.settings.ui?.customThemes || {}),
...(loadedSettings.workspace.settings.ui?.customThemes || {}), ...(loadedSettings.workspace.settings.ui?.customThemes || {}),
+20 -9
View File
@@ -30,6 +30,18 @@ export const getAsciiArtWidth = (asciiArt: string): number => {
* code units so that surrogatepair emoji count as one "column".) * code units so that surrogatepair emoji count as one "column".)
* ---------------------------------------------------------------------- */ * ---------------------------------------------------------------------- */
/**
* Checks if a string contains only ASCII characters (0-127).
*/
export function isAscii(str: string): boolean {
for (let i = 0; i < str.length; i++) {
if (str.charCodeAt(i) > 127) {
return false;
}
}
return true;
}
// Cache for code points // Cache for code points
const MAX_STRING_LENGTH_TO_CACHE = 1000; const MAX_STRING_LENGTH_TO_CACHE = 1000;
const codePointsCache = new LRUCache<string, string[]>( const codePointsCache = new LRUCache<string, string[]>(
@@ -37,15 +49,8 @@ const codePointsCache = new LRUCache<string, string[]>(
); );
export function toCodePoints(str: string): string[] { export function toCodePoints(str: string): string[] {
// ASCII fast path - check if all chars are ASCII (0-127) // ASCII fast path
let isAscii = true; if (isAscii(str)) {
for (let i = 0; i < str.length; i++) {
if (str.charCodeAt(i) > 127) {
isAscii = false;
break;
}
}
if (isAscii) {
return str.split(''); return str.split('');
} }
@@ -68,6 +73,9 @@ export function toCodePoints(str: string): string[] {
} }
export function cpLen(str: string): number { export function cpLen(str: string): number {
if (isAscii(str)) {
return str.length;
}
return toCodePoints(str).length; return toCodePoints(str).length;
} }
@@ -79,6 +87,9 @@ export function cpIndexToOffset(str: string, cpIndex: number): number {
} }
export function cpSlice(str: string, start: number, end?: number): string { export function cpSlice(str: string, start: number, end?: number): string {
if (isAscii(str)) {
return str.slice(start, end);
}
// Slice by codepoint indices and rejoin. // Slice by codepoint indices and rejoin.
const arr = toCodePoints(str).slice(start, end); const arr = toCodePoints(str).slice(start, end);
return arr.join(''); return arr.join('');
+2
View File
@@ -918,6 +918,7 @@ export class Config {
await this.getSkillManager().discoverSkills( await this.getSkillManager().discoverSkills(
this.storage, this.storage,
this.getExtensions(), this.getExtensions(),
this.isTrustedFolder(),
); );
this.getSkillManager().setDisabledSkills(this.disabledSkills); this.getSkillManager().setDisabledSkills(this.disabledSkills);
@@ -1924,6 +1925,7 @@ export class Config {
await this.getSkillManager().discoverSkills( await this.getSkillManager().discoverSkills(
this.storage, this.storage,
this.getExtensions(), this.getExtensions(),
this.isTrustedFolder(),
); );
this.getSkillManager().setDisabledSkills(this.disabledSkills); this.getSkillManager().setDisabledSkills(this.disabledSkills);
@@ -40,6 +40,7 @@ describe('ContextManager', () => {
getMcpClientManager: vi.fn().mockReturnValue({ getMcpClientManager: vi.fn().mockReturnValue({
getMcpInstructions: vi.fn().mockReturnValue('MCP Instructions'), getMcpInstructions: vi.fn().mockReturnValue('MCP Instructions'),
}), }),
isTrustedFolder: vi.fn().mockReturnValue(true),
} as unknown as Config; } as unknown as Config;
contextManager = new ContextManager(mockConfig); contextManager = new ContextManager(mockConfig);
@@ -112,6 +113,24 @@ describe('ContextManager', () => {
fileCount: 2, fileCount: 2,
}); });
}); });
it('should not load environment memory if folder is not trusted', async () => {
vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(false);
const mockGlobalResult = {
files: [
{ path: '/home/user/.gemini/GEMINI.md', content: 'Global Content' },
],
};
vi.mocked(memoryDiscovery.loadGlobalMemory).mockResolvedValue(
mockGlobalResult,
);
await contextManager.refresh();
expect(memoryDiscovery.loadEnvironmentMemory).not.toHaveBeenCalled();
expect(contextManager.getEnvironmentMemory()).toBe('');
expect(contextManager.getGlobalMemory()).toContain('Global Content');
});
}); });
describe('discoverContext', () => { describe('discoverContext', () => {
@@ -150,5 +169,16 @@ describe('ContextManager', () => {
expect(result).toBe(''); expect(result).toBe('');
}); });
it('should return empty string if folder is not trusted', async () => {
vi.mocked(mockConfig.isTrustedFolder).mockReturnValue(false);
const result = await contextManager.discoverContext('/app/src/file.ts', [
'/app',
]);
expect(memoryDiscovery.loadJitSubdirectoryMemory).not.toHaveBeenCalled();
expect(result).toBe('');
});
}); });
}); });
+8 -3
View File
@@ -43,6 +43,10 @@ export class ContextManager {
} }
private async loadEnvironmentMemory(): Promise<void> { private async loadEnvironmentMemory(): Promise<void> {
if (!this.config.isTrustedFolder()) {
this.environmentMemory = '';
return;
}
const result = await loadEnvironmentMemory( const result = await loadEnvironmentMemory(
[...this.config.getWorkspaceContext().getDirectories()], [...this.config.getWorkspaceContext().getDirectories()],
this.config.getExtensionLoader(), this.config.getExtensionLoader(),
@@ -68,6 +72,9 @@ export class ContextManager {
accessedPath: string, accessedPath: string,
trustedRoots: string[], trustedRoots: string[],
): Promise<string> { ): Promise<string> {
if (!this.config.isTrustedFolder()) {
return '';
}
const result = await loadJitSubdirectoryMemory( const result = await loadJitSubdirectoryMemory(
accessedPath, accessedPath,
trustedRoots, trustedRoots,
@@ -101,9 +108,7 @@ export class ContextManager {
} }
private markAsLoaded(paths: string[]): void { private markAsLoaded(paths: string[]): void {
for (const p of paths) { paths.forEach((p) => this.loadedPaths.add(p));
this.loadedPaths.add(p);
}
} }
getLoadedPaths(): ReadonlySet<string> { getLoadedPaths(): ReadonlySet<string> {
+71 -7
View File
@@ -78,13 +78,19 @@ description: project-desc
}; };
vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue(userDir); vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue(userDir);
vi.spyOn(Storage, 'getUserAgentSkillsDir').mockReturnValue(
'/non-existent-user-agent',
);
const storage = new Storage('/dummy'); const storage = new Storage('/dummy');
vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue(projectDir); vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue(projectDir);
vi.spyOn(storage, 'getProjectAgentSkillsDir').mockReturnValue(
'/non-existent-project-agent',
);
const service = new SkillManager(); const service = new SkillManager();
// @ts-expect-error accessing private method for testing // @ts-expect-error accessing private method for testing
vi.spyOn(service, 'discoverBuiltinSkills').mockResolvedValue(undefined); vi.spyOn(service, 'discoverBuiltinSkills').mockResolvedValue(undefined);
await service.discoverSkills(storage, [mockExtension]); await service.discoverSkills(storage, [mockExtension], true);
const skills = service.getSkills(); const skills = service.getSkills();
expect(skills).toHaveLength(3); expect(skills).toHaveLength(3);
@@ -135,13 +141,19 @@ description: project-desc
}; };
vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue(userDir); vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue(userDir);
vi.spyOn(Storage, 'getUserAgentSkillsDir').mockReturnValue(
'/non-existent-user-agent',
);
const storage = new Storage('/dummy'); const storage = new Storage('/dummy');
vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue(projectDir); vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue(projectDir);
vi.spyOn(storage, 'getProjectAgentSkillsDir').mockReturnValue(
'/non-existent-project-agent',
);
const service = new SkillManager(); const service = new SkillManager();
// @ts-expect-error accessing private method for testing // @ts-expect-error accessing private method for testing
vi.spyOn(service, 'discoverBuiltinSkills').mockResolvedValue(undefined); vi.spyOn(service, 'discoverBuiltinSkills').mockResolvedValue(undefined);
await service.discoverSkills(storage, [mockExtension]); await service.discoverSkills(storage, [mockExtension], true);
const skills = service.getSkills(); const skills = service.getSkills();
expect(skills).toHaveLength(1); expect(skills).toHaveLength(1);
@@ -149,7 +161,7 @@ description: project-desc
// Test User > Extension // Test User > Extension
vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue('/non-existent'); vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue('/non-existent');
await service.discoverSkills(storage, [mockExtension]); await service.discoverSkills(storage, [mockExtension], true);
expect(service.getSkills()[0].description).toBe('user-desc'); expect(service.getSkills()[0].description).toBe('user-desc');
}); });
@@ -173,7 +185,7 @@ description: project-desc
vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue('/non-existent'); vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue('/non-existent');
vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue('/non-existent'); vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue('/non-existent');
await service.discoverSkills(storage); await service.discoverSkills(storage, [], true);
const skills = service.getSkills(); const skills = service.getSkills();
expect(skills).toHaveLength(1); expect(skills).toHaveLength(1);
@@ -196,12 +208,18 @@ body1`,
const storage = new Storage('/dummy'); const storage = new Storage('/dummy');
vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue(testRootDir); vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue(testRootDir);
vi.spyOn(storage, 'getProjectAgentSkillsDir').mockReturnValue(
'/non-existent-project-agent',
);
vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue('/non-existent'); vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue('/non-existent');
vi.spyOn(Storage, 'getUserAgentSkillsDir').mockReturnValue(
'/non-existent-user-agent',
);
const service = new SkillManager(); const service = new SkillManager();
// @ts-expect-error accessing private method for testing // @ts-expect-error accessing private method for testing
vi.spyOn(service, 'discoverBuiltinSkills').mockResolvedValue(undefined); vi.spyOn(service, 'discoverBuiltinSkills').mockResolvedValue(undefined);
await service.discoverSkills(storage); await service.discoverSkills(storage, [], true);
service.setDisabledSkills(['skill1']); service.setDisabledSkills(['skill1']);
expect(service.getSkills()).toHaveLength(0); expect(service.getSkills()).toHaveLength(0);
@@ -209,6 +227,40 @@ body1`,
expect(service.getAllSkills()[0].disabled).toBe(true); expect(service.getAllSkills()[0].disabled).toBe(true);
}); });
it('should skip workspace skills if folder is not trusted', async () => {
const projectDir = path.join(testRootDir, 'workspace');
await fs.mkdir(path.join(projectDir, 'skill-project'), { recursive: true });
await fs.writeFile(
path.join(projectDir, 'skill-project', 'SKILL.md'),
`---
name: skill-project
description: project-desc
---
`,
);
const storage = new Storage('/dummy');
vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue(projectDir);
vi.spyOn(storage, 'getProjectAgentSkillsDir').mockReturnValue(
'/non-existent-project-agent',
);
vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue('/non-existent');
vi.spyOn(Storage, 'getUserAgentSkillsDir').mockReturnValue(
'/non-existent-user-agent',
);
const service = new SkillManager();
// @ts-expect-error accessing private method for testing
vi.spyOn(service, 'discoverBuiltinSkills').mockResolvedValue(undefined);
// Call with isTrusted = false
await service.discoverSkills(storage, [], false);
const skills = service.getSkills();
expect(skills).toHaveLength(0);
});
it('should filter built-in skills in getDisplayableSkills', async () => { it('should filter built-in skills in getDisplayableSkills', async () => {
const service = new SkillManager(); const service = new SkillManager();
@@ -303,14 +355,20 @@ body1`,
}); });
vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue(userDir); vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue(userDir);
vi.spyOn(Storage, 'getUserAgentSkillsDir').mockReturnValue(
'/non-existent-user-agent',
);
const storage = new Storage('/dummy'); const storage = new Storage('/dummy');
vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue(projectDir); vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue(projectDir);
vi.spyOn(storage, 'getProjectAgentSkillsDir').mockReturnValue(
'/non-existent-project-agent',
);
const service = new SkillManager(); const service = new SkillManager();
// @ts-expect-error accessing private method for testing // @ts-expect-error accessing private method for testing
vi.spyOn(service, 'discoverBuiltinSkills').mockResolvedValue(undefined); vi.spyOn(service, 'discoverBuiltinSkills').mockResolvedValue(undefined);
await service.discoverSkills(storage, []); await service.discoverSkills(storage, [], true);
expect(emitFeedbackSpy).toHaveBeenCalledWith( expect(emitFeedbackSpy).toHaveBeenCalledWith(
'warning', 'warning',
@@ -356,12 +414,18 @@ body1`,
}); });
vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue(userDir); vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue(userDir);
vi.spyOn(Storage, 'getUserAgentSkillsDir').mockReturnValue(
'/non-existent-user-agent',
);
const storage = new Storage('/dummy'); const storage = new Storage('/dummy');
vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue('/non-existent'); vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue('/non-existent');
vi.spyOn(storage, 'getProjectAgentSkillsDir').mockReturnValue(
'/non-existent-project-agent',
);
const service = new SkillManager(); const service = new SkillManager();
await service.discoverSkills(storage, []); await service.discoverSkills(storage, [], true);
// UI warning should not be called // UI warning should not be called
expect(emitFeedbackSpy).not.toHaveBeenCalled(); expect(emitFeedbackSpy).not.toHaveBeenCalled();
+8
View File
@@ -47,6 +47,7 @@ export class SkillManager {
async discoverSkills( async discoverSkills(
storage: Storage, storage: Storage,
extensions: GeminiCLIExtension[] = [], extensions: GeminiCLIExtension[] = [],
isTrusted: boolean = false,
): Promise<void> { ): Promise<void> {
this.clearSkills(); this.clearSkills();
@@ -71,6 +72,13 @@ export class SkillManager {
this.addSkillsWithPrecedence(userAgentSkills); this.addSkillsWithPrecedence(userAgentSkills);
// 4. Workspace skills (highest precedence) // 4. Workspace skills (highest precedence)
if (!isTrusted) {
debugLogger.debug(
'Workspace skills disabled because folder is not trusted.',
);
return;
}
const projectSkills = await loadSkillsFromDir( const projectSkills = await loadSkillsFromDir(
storage.getProjectSkillsDir(), storage.getProjectSkillsDir(),
); );
@@ -112,7 +112,7 @@ describe('SkillManager Alias', () => {
// @ts-expect-error accessing private method for testing // @ts-expect-error accessing private method for testing
vi.spyOn(service, 'discoverBuiltinSkills').mockResolvedValue(undefined); vi.spyOn(service, 'discoverBuiltinSkills').mockResolvedValue(undefined);
await service.discoverSkills(storage, []); await service.discoverSkills(storage, [], true);
const skills = service.getSkills(); const skills = service.getSkills();
expect(skills).toHaveLength(4); expect(skills).toHaveLength(4);
@@ -169,7 +169,7 @@ describe('SkillManager Alias', () => {
// @ts-expect-error accessing private method for testing // @ts-expect-error accessing private method for testing
vi.spyOn(service, 'discoverBuiltinSkills').mockResolvedValue(undefined); vi.spyOn(service, 'discoverBuiltinSkills').mockResolvedValue(undefined);
await service.discoverSkills(storage, []); await service.discoverSkills(storage, [], true);
const skills = service.getSkills(); const skills = service.getSkills();
expect(skills).toHaveLength(1); expect(skills).toHaveLength(1);