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
+157 -1
View File
@@ -105,7 +105,7 @@ vi.mock('fs', async (importOriginal) => {
readFileSync: vi.fn(),
writeFileSync: 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) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
const os = await import('node:os');
return {
...actual,
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', () => {
@@ -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.systemDefaults = systemDefaults;
this.user = user;
this.workspace = workspace;
this._workspaceFile = workspace;
this.isTrusted = isTrusted;
this.workspace = isTrusted
? workspace
: this.createEmptyWorkspace(workspace);
this.errors = errors;
this._merged = this.computeMergedSettings();
}
@@ -286,10 +289,11 @@ export class LoadedSettings {
readonly system: SettingsFile;
readonly systemDefaults: SettingsFile;
readonly user: SettingsFile;
readonly workspace: SettingsFile;
readonly isTrusted: boolean;
workspace: SettingsFile;
isTrusted: boolean;
readonly errors: SettingsError[];
private _workspaceFile: SettingsFile;
private _merged: MergedSettings;
private _remoteAdminSettings: Partial<Settings> | undefined;
@@ -297,6 +301,26 @@ export class LoadedSettings {
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 {
const merged = mergeSettings(
this.system.settings,
@@ -341,8 +365,21 @@ export class LoadedSettings {
setValue(scope: LoadableSettingScope, key: string, value: unknown): void {
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();
saveSettings(settingsFile);
coreEvents.emitSettingsChanged();
@@ -592,9 +629,10 @@ export function loadSettings(
// For the initial trust check, we can only use user and system settings.
const initialTrustCheckSettings = customDeepMerge(
getMergeStrategyForPath,
{},
systemSettings,
getDefaultsFromSchema(),
systemDefaultSettings,
userSettings,
systemSettings,
);
const isTrusted =
isWorkspaceTrusted(initialTrustCheckSettings as Settings, workspaceDir)
@@ -672,57 +710,55 @@ export function migrateDeprecatedSettings(
removeDeprecated = false,
): boolean {
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 settings = loadedSettings.forScope(scope).settings;
// Migrate inverted boolean settings (disableX -> enableX)
// These settings were renamed and their boolean logic inverted
// Migrate general settings
const generalSettings = settings.general as
| Record<string, unknown>
| 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) {
const newGeneral: Record<string, unknown> = { ...generalSettings };
const newGeneral = { ...generalSettings };
let modified = false;
if (typeof newGeneral['disableAutoUpdate'] === 'boolean') {
if (typeof newGeneral['enableAutoUpdate'] === 'boolean') {
// Both exist, trust the new one
if (removeDeprecated) {
delete newGeneral['disableAutoUpdate'];
modified = true;
}
} else {
const oldValue = newGeneral['disableAutoUpdate'];
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;
}
}
modified =
migrateBoolean(newGeneral, 'disableAutoUpdate', 'enableAutoUpdate') ||
modified;
modified =
migrateBoolean(
newGeneral,
'disableUpdateNag',
'enableAutoUpdateNotification',
) || modified;
if (modified) {
loadedSettings.setValue(scope, 'general', newGeneral);
@@ -731,94 +767,63 @@ export function migrateDeprecatedSettings(
}
// Migrate ui settings
const uiSettings = settings.ui as Record<string, unknown> | undefined;
if (uiSettings) {
const newUi: Record<string, unknown> = { ...uiSettings };
let modified = false;
// Migrate ui.accessibility.disableLoadingPhrases -> ui.accessibility.enableLoadingPhrases
const newUi = { ...uiSettings };
const accessibilitySettings = newUi['accessibility'] as
| Record<string, unknown>
| 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) {
loadedSettings.setValue(scope, 'ui', newUi);
anyModified = true;
if (accessibilitySettings) {
const newAccessibility = { ...accessibilitySettings };
if (
migrateBoolean(
newAccessibility,
'disableLoadingPhrases',
'enableLoadingPhrases',
)
) {
newUi['accessibility'] = newAccessibility;
loadedSettings.setValue(scope, 'ui', newUi);
anyModified = true;
}
}
}
// Migrate context settings
const contextSettings = settings.context as
| Record<string, unknown>
| undefined;
if (contextSettings) {
const newContext: Record<string, unknown> = { ...contextSettings };
let modified = false;
// Migrate context.fileFiltering.disableFuzzySearch -> context.fileFiltering.enableFuzzySearch
const newContext = { ...contextSettings };
const fileFilteringSettings = newContext['fileFiltering'] as
| Record<string, unknown>
| 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) {
loadedSettings.setValue(scope, 'context', newContext);
anyModified = true;
if (fileFilteringSettings) {
const newFileFiltering = { ...fileFilteringSettings };
if (
migrateBoolean(
newFileFiltering,
'disableFuzzySearch',
'enableFuzzySearch',
)
) {
newContext['fileFiltering'] = newFileFiltering;
loadedSettings.setValue(scope, 'context', newContext);
anyModified = true;
}
}
}
// Migrate experimental agent settings
anyModified ||= migrateExperimentalSettings(
settings,
loadedSettings,
scope,
removeDeprecated,
);
anyModified =
migrateExperimentalSettings(
settings,
loadedSettings,
scope,
removeDeprecated,
) || anyModified;
};
processScope(SettingScope.User);
+324 -66
View File
@@ -53,6 +53,7 @@ vi.mock('fs', async (importOriginal) => {
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
realpathSync: vi.fn((p) => p),
};
});
vi.mock('strip-json-comments', () => ({
@@ -60,22 +61,23 @@ vi.mock('strip-json-comments', () => ({
}));
describe('Trusted Folders Loading', () => {
let mockFsExistsSync: Mocked<typeof fs.existsSync>;
let mockStripJsonComments: Mocked<typeof stripJsonComments>;
let mockFsWriteFileSync: Mocked<typeof fs.writeFileSync>;
beforeEach(() => {
resetTrustedFoldersForTesting();
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('{}');
vi.mocked(fs.existsSync).mockReturnValue(false);
vi.mocked(fs.readFileSync).mockReturnValue('{}');
vi.mocked(fs.realpathSync).mockImplementation((p: fs.PathLike) =>
p.toString(),
);
});
afterEach(() => {
@@ -90,13 +92,16 @@ describe('Trusted Folders Loading', () => {
describe('isPathTrusted', () => {
function setup({ config = {} as Record<string, TrustLevel> } = {}) {
(mockFsExistsSync as Mock).mockImplementation(
(p) => p === getTrustedFoldersPath(),
vi.mocked(fs.existsSync).mockImplementation(
(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();
@@ -124,26 +129,62 @@ describe('Trusted Folders Loading', () => {
expect(folders.isPathTrusted('/trustedparent/trustme')).toBe(true);
// No explicit rule covers this file
expect(folders.isPathTrusted('/secret/bankaccounts.json')).toBe(
undefined,
);
expect(folders.isPathTrusted('/secret/mine/privatekey.pem')).toBe(
undefined,
);
expect(folders.isPathTrusted('/secret/bankaccounts.json')).toBe(false);
expect(folders.isPathTrusted('/secret/mine/privatekey.pem')).toBe(false);
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', () => {
const userPath = getTrustedFoldersPath();
(mockFsExistsSync as Mock).mockImplementation((p) => p === userPath);
vi.mocked(fs.existsSync).mockImplementation(
(p: fs.PathLike) => p.toString() === userPath,
);
const userContent = {
'/user/folder': TrustLevel.TRUST_FOLDER,
};
(fs.readFileSync as Mock).mockImplementation((p) => {
if (p === userPath) return JSON.stringify(userContent);
return '{}';
});
vi.mocked(fs.readFileSync).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === userPath) return JSON.stringify(userContent);
return '{}';
},
);
const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([
@@ -154,11 +195,15 @@ describe('Trusted Folders Loading', () => {
it('should handle JSON parsing errors gracefully', () => {
const userPath = getTrustedFoldersPath();
(mockFsExistsSync as Mock).mockImplementation((p) => p === userPath);
(fs.readFileSync as Mock).mockImplementation((p) => {
if (p === userPath) return 'invalid json';
return '{}';
});
vi.mocked(fs.existsSync).mockImplementation(
(p: fs.PathLike) => p.toString() === userPath,
);
vi.mocked(fs.readFileSync).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === userPath) return 'invalid json';
return '{}';
},
);
const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([]);
@@ -171,14 +216,18 @@ describe('Trusted Folders Loading', () => {
const customPath = '/custom/path/to/trusted_folders.json';
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 = {
'/user/folder/from/env': TrustLevel.TRUST_FOLDER,
};
(fs.readFileSync as Mock).mockImplementation((p) => {
if (p === customPath) return JSON.stringify(userContent);
return '{}';
});
vi.mocked(fs.readFileSync).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === customPath) return JSON.stringify(userContent);
return '{}';
},
);
const { rules, errors } = loadTrustedFolders();
expect(rules).toEqual([
@@ -221,14 +270,16 @@ describe('isWorkspaceTrusted', () => {
beforeEach(() => {
resetTrustedFoldersForTesting();
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
if (p === getTrustedFoldersPath()) {
return JSON.stringify(mockRules);
}
return '{}';
});
vi.spyOn(fs, 'readFileSync').mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === getTrustedFoldersPath()) {
return JSON.stringify(mockRules);
}
return '{}';
},
);
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', () => {
mockCwd = '/home/user/projectA';
// This mock needs to be specific to this test to override the one in beforeEach
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
if (p === getTrustedFoldersPath()) {
return '{"foo": "bar",}'; // Malformed JSON with trailing comma
}
return '{}';
});
vi.spyOn(fs, 'readFileSync').mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === getTrustedFoldersPath()) {
return '{"foo": "bar",}'; // Malformed JSON with trailing comma
}
return '{}';
},
);
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError);
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(
/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', () => {
mockCwd = '/home/user/projectA';
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
if (p === getTrustedFoldersPath()) {
return 'null';
}
return '{}';
});
vi.spyOn(fs, 'readFileSync').mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === getTrustedFoldersPath()) {
return 'null';
}
return '{}';
},
);
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(FatalConfigError);
expect(() => isWorkspaceTrusted(mockSettings)).toThrow(
/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';
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', () => {
@@ -316,12 +371,12 @@ describe('isWorkspaceTrusted', () => {
expect(isWorkspaceTrusted(mockSettings).isTrusted).toBeUndefined();
});
it('should prioritize trust over distrust', () => {
it('should prioritize specific distrust over parent trust', () => {
mockCwd = '/home/user/projectA/untrusted';
mockRules['/home/user/projectA'] = TrustLevel.TRUST_FOLDER;
mockRules['/home/user/projectA/untrusted'] = TrustLevel.DO_NOT_TRUST;
expect(isWorkspaceTrusted(mockSettings)).toEqual({
isTrusted: true,
isTrusted: false,
source: 'file',
});
});
@@ -351,6 +406,19 @@ describe('isWorkspaceTrusted', () => {
});
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(() => {
vi.clearAllMocks();
ideContextStore.clear();
@@ -390,10 +458,15 @@ describe('isWorkspaceTrusted with IDE override', () => {
});
it('should fall back to config when ideTrust is undefined', () => {
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'readFileSync').mockReturnValue(
JSON.stringify({ [process.cwd()]: TrustLevel.TRUST_FOLDER }),
vi.spyOn(fs, 'existsSync').mockImplementation((p) =>
p === getTrustedFoldersPath() || p === mockCwd ? true : false,
);
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
if (p === getTrustedFoldersPath()) {
return JSON.stringify({ [mockCwd]: TrustLevel.TRUST_FOLDER });
}
return '{}';
});
expect(isWorkspaceTrusted(mockSettings)).toEqual({
isTrusted: true,
source: 'file',
@@ -419,8 +492,11 @@ describe('isWorkspaceTrusted with IDE override', () => {
describe('Trusted Folders Caching', () => {
beforeEach(() => {
resetTrustedFoldersForTesting();
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue('{}');
vi.spyOn(fs, 'existsSync').mockReturnValue(true);
vi.spyOn(fs, 'readFileSync').mockReturnValue('{}');
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
p.toString(),
);
});
afterEach(() => {
@@ -454,14 +530,20 @@ describe('invalid trust levels', () => {
beforeEach(() => {
resetTrustedFoldersForTesting();
vi.spyOn(process, 'cwd').mockImplementation(() => mockCwd);
vi.spyOn(fs, 'readFileSync').mockImplementation((p) => {
if (p === getTrustedFoldersPath()) {
return JSON.stringify(mockRules);
}
return '{}';
});
vi.spyOn(fs, 'realpathSync').mockImplementation((p: fs.PathLike) =>
p.toString(),
);
vi.spyOn(fs, 'readFileSync').mockImplementation(
(p: fs.PathOrFileDescriptor) => {
if (p.toString() === getTrustedFoldersPath()) {
return JSON.stringify(mockRules);
}
return '{}';
},
);
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);
});
});
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',
}
export function isTrustLevel(value: unknown): value is TrustLevel {
export function isTrustLevel(
value: string | number | boolean | object | null | undefined,
): value is TrustLevel {
return (
typeof value === 'string' &&
Object.values(TrustLevel).includes(value as TrustLevel)
@@ -63,6 +65,32 @@ export interface TrustResult {
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 {
constructor(
readonly user: TrustedFoldersFile,
@@ -88,39 +116,36 @@ export class LoadedTrustedFolders {
config?: Record<string, TrustLevel>,
): boolean | undefined {
const configToUse = config ?? this.user.config;
const trustedPaths: string[] = [];
const untrustedPaths: string[] = [];
for (const rule of Object.entries(configToUse).map(
([path, trustLevel]) => ({ path, trustLevel }),
)) {
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;
// Resolve location to its realpath for canonical comparison
const realLocation = getRealPath(location);
let longestMatchLen = -1;
let longestMatchTrust: TrustLevel | undefined = undefined;
for (const [rulePath, trustLevel] of Object.entries(configToUse)) {
const effectivePath =
trustLevel === TrustLevel.TRUST_PARENT
? path.dirname(rulePath)
: rulePath;
// Resolve effectivePath to its realpath for canonical comparison
const realEffectivePath = getRealPath(effectivePath);
if (isWithinRoot(realLocation, realEffectivePath)) {
if (rulePath.length > longestMatchLen) {
longestMatchLen = rulePath.length;
longestMatchTrust = trustLevel;
}
}
}
for (const trustedPath of trustedPaths) {
if (isWithinRoot(location, trustedPath)) {
return true;
}
}
for (const untrustedPath of untrustedPaths) {
if (path.normalize(location) === path.normalize(untrustedPath)) {
return false;
}
}
if (longestMatchTrust === TrustLevel.DO_NOT_TRUST) return false;
if (
longestMatchTrust === TrustLevel.TRUST_FOLDER ||
longestMatchTrust === TrustLevel.TRUST_PARENT
)
return true;
return undefined;
}
@@ -150,6 +175,7 @@ let loadedTrustedFolders: LoadedTrustedFolders | undefined;
*/
export function resetTrustedFoldersForTesting(): void {
loadedTrustedFolders = undefined;
clearRealPathCacheForTesting();
}
export function loadTrustedFolders(): LoadedTrustedFolders {
@@ -161,11 +187,13 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
const userConfig: Record<string, TrustLevel> = {};
const userPath = getTrustedFoldersPath();
// Load user trusted folders
try {
if (fs.existsSync(userPath)) {
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 (
typeof parsed !== 'object' ||
@@ -190,7 +218,7 @@ export function loadTrustedFolders(): LoadedTrustedFolders {
}
}
}
} catch (error: unknown) {
} catch (error) {
errors.push({
message: getErrorMessage(error),
path: userPath,