diff --git a/docs/cli/settings.md b/docs/cli/settings.md index a581875a35..ab637aed3e 100644 --- a/docs/cli/settings.md +++ b/docs/cli/settings.md @@ -107,13 +107,14 @@ they appear in the UI. ### Security -| UI Label | Setting | Description | Default | -| ------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------- | ------- | -| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` | -| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` | -| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` | -| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` | -| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` | +| UI Label | Setting | Description | Default | +| ------------------------------------- | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | +| Disable YOLO Mode | `security.disableYoloMode` | Disable YOLO mode, even if enabled by a flag. | `false` | +| Allow Permanent Tool Approval | `security.enablePermanentToolApproval` | Enable the "Allow for all future sessions" option in tool confirmation dialogs. | `false` | +| Blocks extensions from Git | `security.blockGitExtensions` | Blocks installing and loading extensions from Git. | `false` | +| Extension Source Regex Allowlist | `security.allowedExtensions` | List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting. | `[]` | +| Folder Trust | `security.folderTrust.enabled` | Setting to track whether Folder trust is enabled. | `false` | +| Enable Environment Variable Redaction | `security.environmentVariableRedaction.enabled` | Enable redaction of environment variables that may contain secrets. | `false` | ### Experimental diff --git a/docs/get-started/configuration.md b/docs/get-started/configuration.md index 75bb9c46c4..5ce8231a51 100644 --- a/docs/get-started/configuration.md +++ b/docs/get-started/configuration.md @@ -773,6 +773,13 @@ their corresponding top-level category object in your `settings.json` file. - **Default:** `false` - **Requires restart:** Yes +- **`security.allowedExtensions`** (array): + - **Description:** List of Regex patterns for allowed extensions. If nonempty, + only extensions that match the patterns in this list are allowed. Overrides + the blockGitExtensions setting. + - **Default:** `[]` + - **Requires restart:** Yes + - **`security.folderTrust.enabled`** (boolean): - **Description:** Setting to track whether Folder trust is enabled. - **Default:** `false` diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts index 75c4924c32..9e19109eda 100644 --- a/packages/cli/src/config/extension-manager.ts +++ b/packages/cli/src/config/extension-manager.ts @@ -144,6 +144,26 @@ export class ExtensionManager extends ExtensionLoader { previousExtensionConfig?: ExtensionConfig, ): Promise { if ( + this.settings.security?.allowedExtensions && + this.settings.security?.allowedExtensions.length > 0 + ) { + const extensionAllowed = this.settings.security?.allowedExtensions.some( + (pattern) => { + try { + return new RegExp(pattern).test(installMetadata.source); + } catch (e) { + throw new Error( + `Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`, + ); + } + }, + ); + if (!extensionAllowed) { + throw new Error( + `Installing extension from source "${installMetadata.source}" is not allowed by the "allowedExtensions" security setting.`, + ); + } + } else if ( (installMetadata.type === 'git' || installMetadata.type === 'github-release') && this.settings.security.blockGitExtensions @@ -152,6 +172,7 @@ export class ExtensionManager extends ExtensionLoader { 'Installing extensions from remote sources is disallowed by your current settings.', ); } + const isUpdate = !!previousExtensionConfig; let newExtensionConfig: ExtensionConfig | null = null; let localSourcePath: string | undefined; @@ -522,10 +543,39 @@ Would you like to attempt to install via "git clone" instead?`, const installMetadata = loadInstallMetadata(extensionDir); let effectiveExtensionPath = extensionDir; if ( + this.settings.security?.allowedExtensions && + this.settings.security?.allowedExtensions.length > 0 + ) { + if (!installMetadata?.source) { + throw new Error( + `Failed to load extension ${extensionDir}. The ${INSTALL_METADATA_FILENAME} file is missing or misconfigured.`, + ); + } + const extensionAllowed = this.settings.security?.allowedExtensions.some( + (pattern) => { + try { + return new RegExp(pattern).test(installMetadata?.source); + } catch (e) { + throw new Error( + `Invalid regex pattern in allowedExtensions setting: "${pattern}. Error: ${getErrorMessage(e)}`, + ); + } + }, + ); + if (!extensionAllowed) { + debugLogger.warn( + `Failed to load extension ${extensionDir}. This extension is not allowed by the "allowedExtensions" security setting.`, + ); + return null; + } + } else if ( (installMetadata?.type === 'git' || installMetadata?.type === 'github-release') && this.settings.security.blockGitExtensions ) { + debugLogger.warn( + `Failed to load extension ${extensionDir}. Extensions from remote sources is disallowed by your current settings.`, + ); return null; } diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index 7acaf2cc67..0148fc7729 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -622,6 +622,7 @@ describe('extension tests', () => { }); it('should not load github extensions if blockGitExtensions is set', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); createExtension({ extensionsDir: userExtensionsDir, name: 'my-ext', @@ -645,6 +646,73 @@ describe('extension tests', () => { const extension = extensions.find((e) => e.name === 'my-ext'); expect(extension).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Extensions from remote sources is disallowed by your current settings.', + ), + ); + consoleSpy.mockRestore(); + }); + + it('should load allowed extensions if the allowlist is set.', async () => { + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-ext', + version: '1.0.0', + installMetadata: { + type: 'git', + source: 'http://allowed.com/foo/bar', + }, + }); + const extensionAllowlistSetting = createTestMergedSettings({ + security: { + allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'], + }, + }); + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings: extensionAllowlistSetting, + }); + const extensions = await extensionManager.loadExtensions(); + + expect(extensions).toHaveLength(1); + expect(extensions[0].name).toBe('my-ext'); + }); + + it('should not load disallowed extensions if the allowlist is set.', async () => { + const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + createExtension({ + extensionsDir: userExtensionsDir, + name: 'my-ext', + version: '1.0.0', + installMetadata: { + type: 'git', + source: 'http://notallowed.com/foo/bar', + }, + }); + const extensionAllowlistSetting = createTestMergedSettings({ + security: { + allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'], + }, + }); + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings: extensionAllowlistSetting, + }); + const extensions = await extensionManager.loadExtensions(); + const extension = extensions.find((e) => e.name === 'my-ext'); + + expect(extension).toBeUndefined(); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'This extension is not allowed by the "allowedExtensions" security setting', + ), + ); + consoleSpy.mockRestore(); }); it('should not load any extensions if admin.extensions.enabled is false', async () => { @@ -1116,6 +1184,30 @@ describe('extension tests', () => { ); }); + it('should not install a disallowed extension if the allowlist is set', async () => { + const gitUrl = 'https://somehost.com/somerepo.git'; + const allowedExtensionsSetting = createTestMergedSettings({ + security: { + allowedExtensions: ['\\b(https?:\\/\\/)?(www\\.)?allowed\\.com\\S*'], + }, + }); + extensionManager = new ExtensionManager({ + workspaceDir: tempWorkspaceDir, + requestConsent: mockRequestConsent, + requestSetting: mockPromptForSettings, + settings: allowedExtensionsSetting, + }); + await extensionManager.loadExtensions(); + await expect( + extensionManager.installOrUpdateExtension({ + source: gitUrl, + type: 'git', + }), + ).rejects.toThrow( + `Installing extension from source "${gitUrl}" is not allowed by the "allowedExtensions" security setting.`, + ); + }); + it('should prompt for trust if workspace is not trusted', async () => { vi.mocked(isWorkspaceTrusted).mockReturnValue({ isTrusted: false, diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 65e42332d6..a34163ccb3 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1268,6 +1268,17 @@ const SETTINGS_SCHEMA = { description: 'Blocks installing and loading extensions from Git.', showInDialog: true, }, + allowedExtensions: { + type: 'array', + label: 'Extension Source Regex Allowlist', + category: 'Security', + requiresRestart: true, + default: [] as string[], + description: + 'List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting.', + showInDialog: true, + items: { type: 'string' }, + }, folderTrust: { type: 'object', label: 'Folder Trust', diff --git a/schemas/settings.schema.json b/schemas/settings.schema.json index 3254af9d33..d33c75bf63 100644 --- a/schemas/settings.schema.json +++ b/schemas/settings.schema.json @@ -1273,6 +1273,16 @@ "default": false, "type": "boolean" }, + "allowedExtensions": { + "title": "Extension Source Regex Allowlist", + "description": "List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting.", + "markdownDescription": "List of Regex patterns for allowed extensions. If nonempty, only extensions that match the patterns in this list are allowed. Overrides the blockGitExtensions setting.\n\n- Category: `Security`\n- Requires restart: `yes`\n- Default: `[]`", + "default": [], + "type": "array", + "items": { + "type": "string" + } + }, "folderTrust": { "title": "Folder Trust", "description": "Settings for folder trust.",