feat(extensions): enforce folder trust for local extension install (#19703)

This commit is contained in:
Gal Zahavi
2026-02-24 11:58:44 -08:00
committed by GitHub
parent 4dd940f8ce
commit 6510347d5b
9 changed files with 592 additions and 116 deletions
+6 -7
View File
@@ -32,6 +32,7 @@ import {
ExtensionUninstallEvent,
ExtensionUpdateEvent,
getErrorMessage,
getRealPath,
logExtensionDisable,
logExtensionEnable,
logExtensionInstallEvent,
@@ -202,13 +203,11 @@ export class ExtensionManager extends ExtensionLoader {
const extensionsDir = ExtensionStorage.getUserExtensionsDir();
await fs.promises.mkdir(extensionsDir, { recursive: true });
if (
!path.isAbsolute(installMetadata.source) &&
(installMetadata.type === 'local' || installMetadata.type === 'link')
) {
installMetadata.source = path.resolve(
this.workspaceDir,
installMetadata.source,
if (installMetadata.type === 'local' || installMetadata.type === 'link') {
installMetadata.source = getRealPath(
path.isAbsolute(installMetadata.source)
? installMetadata.source
: path.resolve(this.workspaceDir, installMetadata.source),
);
}
+64 -56
View File
@@ -25,6 +25,7 @@ import {
KeychainTokenStorage,
loadAgentsFromDirectory,
loadSkillsFromDir,
getRealPath,
} from '@google/gemini-cli-core';
import {
loadSettings,
@@ -186,11 +187,11 @@ describe('extension tests', () => {
errors: [],
});
vi.mocked(loadSkillsFromDir).mockResolvedValue([]);
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
tempHomeDir = getRealPath(
fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-home-')),
);
tempWorkspaceDir = fs.mkdtempSync(
path.join(tempHomeDir, 'gemini-cli-test-workspace-'),
tempWorkspaceDir = getRealPath(
fs.mkdtempSync(path.join(tempHomeDir, 'gemini-cli-test-workspace-')),
);
userExtensionsDir = path.join(tempHomeDir, EXTENSIONS_DIRECTORY_NAME);
mockRequestConsent = vi.fn();
@@ -329,12 +330,14 @@ describe('extension tests', () => {
});
it('should load a linked extension correctly', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempWorkspaceDir,
name: 'my-linked-extension',
version: '1.0.0',
contextFileName: 'context.md',
});
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'my-linked-extension',
version: '1.0.0',
contextFileName: 'context.md',
}),
);
fs.writeFileSync(path.join(sourceExtDir, 'context.md'), 'linked context');
await extensionManager.loadExtensions();
@@ -361,18 +364,20 @@ describe('extension tests', () => {
});
it('should hydrate ${extensionPath} correctly for linked extensions', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempWorkspaceDir,
name: 'my-linked-extension-with-path',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node',
args: ['${extensionPath}${/}server${/}index.js'],
cwd: '${extensionPath}${/}server',
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempWorkspaceDir,
name: 'my-linked-extension-with-path',
version: '1.0.0',
mcpServers: {
'test-server': {
command: 'node',
args: ['${extensionPath}${/}server${/}index.js'],
cwd: '${extensionPath}${/}server',
},
},
},
});
}),
);
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
@@ -844,11 +849,13 @@ describe('extension tests', () => {
it('should generate id from the original source for linked extensions', async () => {
const extDevelopmentDir = path.join(tempHomeDir, 'local_extensions');
const actualExtensionDir = createExtension({
extensionsDir: extDevelopmentDir,
name: 'link-ext-name',
version: '1.0.0',
});
const actualExtensionDir = getRealPath(
createExtension({
extensionsDir: extDevelopmentDir,
name: 'link-ext-name',
version: '1.0.0',
}),
);
await extensionManager.loadExtensions();
await extensionManager.installOrUpdateExtension({
type: 'link',
@@ -994,11 +1001,13 @@ describe('extension tests', () => {
describe('installExtension', () => {
it('should install an extension from a local path', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
});
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
}),
);
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
@@ -1040,7 +1049,7 @@ describe('extension tests', () => {
});
it('should throw an error and cleanup if gemini-extension.json is missing', async () => {
const sourceExtDir = path.join(tempHomeDir, 'bad-extension');
const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-extension'));
fs.mkdirSync(sourceExtDir, { recursive: true });
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
@@ -1056,7 +1065,7 @@ describe('extension tests', () => {
});
it('should throw an error for invalid JSON in gemini-extension.json', async () => {
const sourceExtDir = path.join(tempHomeDir, 'bad-json-ext');
const sourceExtDir = getRealPath(path.join(tempHomeDir, 'bad-json-ext'));
fs.mkdirSync(sourceExtDir, { recursive: true });
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
fs.writeFileSync(configPath, '{ "name": "bad-json", "version": "1.0.0"'); // Malformed JSON
@@ -1066,22 +1075,17 @@ describe('extension tests', () => {
source: sourceExtDir,
type: 'local',
}),
).rejects.toThrow(
new RegExp(
`^Failed to load extension config from ${configPath.replace(
/\\/g,
'\\\\',
)}`,
),
);
).rejects.toThrow(`Failed to load extension config from ${configPath}`);
});
it('should throw an error for missing name in gemini-extension.json', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'missing-name-ext',
version: '1.0.0',
});
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempHomeDir,
name: 'missing-name-ext',
version: '1.0.0',
}),
);
const configPath = path.join(sourceExtDir, EXTENSIONS_CONFIG_FILENAME);
// Overwrite with invalid config
fs.writeFileSync(configPath, JSON.stringify({ version: '1.0.0' }));
@@ -1134,11 +1138,13 @@ describe('extension tests', () => {
});
it('should install a linked extension', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-linked-extension',
version: '1.0.0',
});
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempHomeDir,
name: 'my-linked-extension',
version: '1.0.0',
}),
);
const targetExtDir = path.join(userExtensionsDir, 'my-linked-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
const configPath = path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME);
@@ -1439,11 +1445,13 @@ ${INSTALL_WARNING_MESSAGE}`,
});
it('should save the autoUpdate flag to the install metadata', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
});
const sourceExtDir = getRealPath(
createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
}),
);
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
@@ -84,7 +84,7 @@ describe('consent', () => {
{ input: '', expected: true },
{ input: 'n', expected: false },
{ input: 'N', expected: false },
{ input: 'yes', expected: false },
{ input: 'yes', expected: true },
])(
'should return $expected for input "$input"',
async ({ input, expected }) => {
+10 -3
View File
@@ -91,10 +91,12 @@ export async function requestConsentInteractive(
* This should not be called from interactive mode as it will break the CLI.
*
* @param prompt A yes/no prompt to ask the user
* @returns Whether or not the user answers 'y' (yes). Defaults to 'yes' on enter.
* @param defaultValue Whether to resolve as true or false on enter.
* @returns Whether or not the user answers 'y' (yes).
*/
async function promptForConsentNonInteractive(
export async function promptForConsentNonInteractive(
prompt: string,
defaultValue = true,
): Promise<boolean> {
const readline = await import('node:readline');
const rl = readline.createInterface({
@@ -105,7 +107,12 @@ async function promptForConsentNonInteractive(
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
rl.close();
resolve(['y', ''].includes(answer.trim().toLowerCase()));
const trimmedAnswer = answer.trim().toLowerCase();
if (trimmedAnswer === '') {
resolve(defaultValue);
} else {
resolve(['y', 'yes'].includes(trimmedAnswer));
}
});
});
}
+27 -12
View File
@@ -75,6 +75,7 @@ import {
SettingScope,
LoadedSettings,
sanitizeEnvVar,
createTestMergedSettings,
} from './settings.js';
import {
FatalConfigError,
@@ -1838,36 +1839,50 @@ describe('Settings Loading and Merging', () => {
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
});
it('does not load env files from untrusted spaces', () => {
it('does not load env files from untrusted spaces when sandboxed', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = {
security: { folderTrust: { enabled: true } },
tools: { sandbox: true },
} as Settings;
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
expect(process.env['TESTTEST']).not.toEqual('1234');
});
it('does not load env files when trust is undefined', () => {
it('does load env files from untrusted spaces when NOT sandboxed', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = {
security: { folderTrust: { enabled: true } },
tools: { sandbox: false },
} as Settings;
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
expect(process.env['TESTTEST']).toEqual('1234');
});
it('does not load env files when trust is undefined and sandboxed', () => {
delete process.env['TESTTEST'];
// isWorkspaceTrusted returns {isTrusted: undefined} for matched rules with no trust value, or no matching rules.
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: undefined });
const settings = {
security: { folderTrust: { enabled: true } },
tools: { sandbox: true },
} as Settings;
const mockTrustFn = vi.fn().mockReturnValue({ isTrusted: undefined });
loadEnvironment(settings, MOCK_WORKSPACE_DIR, mockTrustFn);
expect(process.env['TESTTEST']).not.toEqual('1234');
expect(process.env['GEMINI_API_KEY']).not.toEqual('test-key');
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
});
it('loads whitelisted env files from untrusted spaces if sandboxing is enabled', () => {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = loadSettings(MOCK_WORKSPACE_DIR);
settings.merged.tools.sandbox = true;
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
const settings = createTestMergedSettings({
tools: { sandbox: true },
});
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
// GEMINI_API_KEY is in the whitelist, so it should be loaded.
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
@@ -1880,10 +1895,10 @@ describe('Settings Loading and Merging', () => {
process.argv.push('-s');
try {
setup({ isFolderTrustEnabled: true, isWorkspaceTrustedValue: false });
const settings = loadSettings(MOCK_WORKSPACE_DIR);
// Ensure sandbox is NOT in settings to test argv sniffing
settings.merged.tools.sandbox = undefined;
loadEnvironment(settings.merged, MOCK_WORKSPACE_DIR);
const settings = createTestMergedSettings({
tools: { sandbox: false },
});
loadEnvironment(settings, MOCK_WORKSPACE_DIR, isWorkspaceTrusted);
expect(process.env['GEMINI_API_KEY']).toEqual('test-key');
expect(process.env['TESTTEST']).not.toEqual('1234');
@@ -2782,7 +2797,7 @@ describe('Settings Loading and Merging', () => {
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
expect(process.env['GEMINI_API_KEY']).toEqual('secret');
});
it('should NOT be tricked by positional arguments that look like flags', () => {
@@ -2801,7 +2816,7 @@ describe('Settings Loading and Merging', () => {
MOCK_WORKSPACE_DIR,
);
expect(process.env['GEMINI_API_KEY']).toBeUndefined();
expect(process.env['GEMINI_API_KEY']).toEqual('secret');
});
});
-4
View File
@@ -573,10 +573,6 @@ export function loadEnvironment(
relevantArgs.includes('-s') ||
relevantArgs.includes('--sandbox');
if (trustResult.isTrusted !== true && !isSandboxed) {
return;
}
// Cloud Shell environment variable handling
if (process.env['CLOUD_SHELL'] === 'true') {
setUpCloudShellEnvironment(envFilePath, isTrusted, isSandboxed);