diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 34e81e2c48078..b1196741f2a29 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -148,6 +148,12 @@ function f(x: number, y: string): void { } - Avoid using events to drive control flow between components. Instead, prefer direct method calls or service interactions to ensure clearer dependencies and easier traceability of logic. Events should be reserved for broadcasting state changes or notifications rather than orchestrating behavior across components. - Service dependencies MUST be declared in constructors and MUST NOT be accessed through the `IInstantiationService` at any other point in time. +## Integrated browser chat tools (enterprise) + +Administrators can enable integrated browser tools for agents (`workbench.browser.enableChatTools`, enterprise policy **BrowserChatTools**) and optionally restrict which hosts those tools may open or interact with using `workbench.browser.chatTools.allowedDomains` (policy **BrowserChatToolsAllowedDomains**). The value is a string array; when it is empty, no host restriction is applied. Wildcards such as `*.example.com` follow the same rules as `chat.agent.allowedNetworkDomains`. This restriction applies only to integrated browser **chat** tools; it does not change the fetch tool or other agent network filtering (`chat.agent.networkFilter`, `chat.agent.allowedNetworkDomains`). + +Example (device policy JSON): `BrowserChatTools: true`, `BrowserChatToolsAllowedDomains: ["localhost", "127.0.0.1"]`. + ## Learnings - Minimize the amount of assertions in tests. Prefer one snapshot-style `assert.deepStrictEqual` over multiple precise assertions, as they are much more difficult to understand and to update. - Do not stub a global object (e.g. `(mainWindow as any).ResizeObserver = ...`) or use `any` casts to install fakes in tests. Instead, make the dependency injectable: add an optional constructor parameter on the production class that defaults to the real implementation (e.g. `targetWindow.ResizeObserver`), and have the test pass a fake that implements the real interface. diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index dd82464f26c8d..98713fcb6cc73 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -454,6 +454,21 @@ "default": false, "included": true }, + { + "key": "workbench.browser.chatTools.allowedDomains", + "name": "BrowserChatToolsAllowedDomains", + "category": "InteractiveSession", + "minimumVersion": "1.120", + "localization": { + "description": { + "key": "browser.chatTools.allowedDomains", + "value": "When non-empty, integrated browser chat tools (`#workbench.browser.enableChatTools#`) may only open, navigate to, or interact with pages whose host matches an entry. Supports wildcards such as `*.example.com` (matches `example.com` and any subdomain). Entries like `localhost` and `127.0.0.1` are supported. File URLs and pages without a host always pass. An empty array means no extra restriction. This applies only to browser chat tools; it does not change the fetch tool or other agent network settings (`#chat.agent.allowedNetworkDomains#`, `#chat.agent.networkFilter#`)." + } + }, + "type": "array", + "default": [], + "included": true + }, { "key": "github.copilot.nextEditSuggestions.enabled", "name": "CopilotNextEditSuggestions", diff --git a/src/vs/platform/networkFilter/common/domainMatcher.ts b/src/vs/platform/networkFilter/common/domainMatcher.ts index aafc49ea66148..91700ed73116b 100644 --- a/src/vs/platform/networkFilter/common/domainMatcher.ts +++ b/src/vs/platform/networkFilter/common/domainMatcher.ts @@ -124,6 +124,23 @@ export function matchesDomainPattern(domain: string, pattern: string): boolean { if (!normalizedPattern) { return false; } + return matchesNormalizedDomainPattern(domain, normalizedPattern); +} + +/** + * Like {@link matchesDomainPattern} but normalizes the pattern as a URL/host entry + * (same as `fromUrl: true`), so values such as `localhost` and `127.0.0.1` are valid + * in enterprise allowlists. + */ +export function matchesDomainPolicyPattern(domain: string, pattern: string): boolean { + const normalizedPattern = normalizeDomain(extractDomainPattern(pattern), true); + if (!normalizedPattern) { + return false; + } + return matchesNormalizedDomainPattern(domain, normalizedPattern); +} + +function matchesNormalizedDomainPattern(domain: string, normalizedPattern: string): boolean { if (normalizedPattern === '*') { return true; } diff --git a/src/vs/platform/networkFilter/test/common/domainMatcher.test.ts b/src/vs/platform/networkFilter/test/common/domainMatcher.test.ts index 43c70da1eb22f..d4b664d91ecc6 100644 --- a/src/vs/platform/networkFilter/test/common/domainMatcher.test.ts +++ b/src/vs/platform/networkFilter/test/common/domainMatcher.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { normalizeDomain, extractDomainPattern, matchesDomainPattern, extractDomainFromUri, isDomainAllowed } from '../../common/domainMatcher.js'; +import { normalizeDomain, extractDomainPattern, matchesDomainPattern, matchesDomainPolicyPattern, extractDomainFromUri, isDomainAllowed } from '../../common/domainMatcher.js'; suite('domainMatcher', () => { @@ -136,6 +136,22 @@ suite('domainMatcher', () => { }); }); + suite('matchesDomainPolicyPattern', () => { + + test('allows bare localhost and loopback IP patterns', () => { + assert.strictEqual(matchesDomainPolicyPattern('localhost', 'localhost'), true); + assert.strictEqual(matchesDomainPolicyPattern('127.0.0.1', '127.0.0.1'), true); + }); + + test('bare localhost does not match unrelated hosts', () => { + assert.strictEqual(matchesDomainPolicyPattern('evil.com', 'localhost'), false); + }); + + test('wildcard prefix matches nested subdomains', () => { + assert.strictEqual(matchesDomainPolicyPattern('dev.internal.example.com', '*.example.com'), true); + }); + }); + suite('extractDomainFromUri', () => { test('extracts domain from https URI', () => { diff --git a/src/vs/workbench/contrib/browserView/common/browserChatToolsAllowedDomains.ts b/src/vs/workbench/contrib/browserView/common/browserChatToolsAllowedDomains.ts new file mode 100644 index 0000000000000..be5bd88ca7d67 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/common/browserChatToolsAllowedDomains.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../base/common/uri.js'; +import { extractDomainFromUri, matchesDomainPolicyPattern } from '../../../../platform/networkFilter/common/domainMatcher.js'; + +export const BrowserChatToolsAllowedDomainsSettingId = 'workbench.browser.chatTools.allowedDomains'; + +/** + * When {@link allowedDomains} is empty, all URLs pass. Otherwise only URLs whose host + * matches at least one pattern pass. File URLs and URIs without an authority always pass + * (aligned with {@link IAgentNetworkFilterService.isUriAllowed}). + * + * Does not apply to the fetch tool or other network capabilities — browser chat tools only. + */ +export function isAllowedDomain(url: string, allowedDomains: readonly string[]): boolean { + if (allowedDomains.length === 0) { + return true; + } + + let uri: URI; + try { + uri = URI.parse(url); + } catch { + return false; + } + + if (uri.scheme === 'file' || !uri.authority) { + return true; + } + + const domain = extractDomainFromUri(uri); + if (!domain) { + return true; + } + + return allowedDomains.some(pattern => matchesDomainPolicyPattern(domain, pattern)); +} diff --git a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts index 9d806cae05e1e..418dfe4050b7f 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/features/browserEditorChatFeatures.ts @@ -39,6 +39,7 @@ import product from '../../../../../platform/product/common/product.js'; import { AgentHostEnabledSettingId } from '../../../../../platform/agentHost/common/agentService.js'; import { workbenchConfigurationNodeBase } from '../../../../common/configuration.js'; import { safeSetInnerHtml } from '../../../../../base/browser/domSanitize.js'; +import { BrowserChatToolsAllowedDomainsSettingId } from '../../common/browserChatToolsAllowedDomains.js'; import { BrowserActionCategory } from '../browserViewActions.js'; import { AgentHostChatToolsEnabledSettingId } from '../browserViewWorkbenchService.js'; @@ -482,6 +483,30 @@ Registry.as(ConfigurationExtensions.Configuration).regis experiment: { mode: 'startup' }, tags: ['experimental', 'advanced'], included: product.quality !== 'stable', + }, + [BrowserChatToolsAllowedDomainsSettingId]: { + type: 'array', + items: { type: 'string' }, + default: [], + restricted: true, + markdownDescription: localize( + 'browser.chatTools.allowedDomains', + 'When non-empty, integrated browser chat tools (`#workbench.browser.enableChatTools#`) may only open, navigate to, or interact with pages whose host matches an entry. Supports wildcards such as `*.example.com` (matches `example.com` and any subdomain). Entries like `localhost` and `127.0.0.1` are supported. File URLs and pages without a host always pass. An empty array means no extra restriction. This applies only to browser chat tools; it does not change the fetch tool or other agent network settings (`#chat.agent.allowedNetworkDomains#`, `#chat.agent.networkFilter#`).' + ), + policy: { + name: 'BrowserChatToolsAllowedDomains', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.120', + localization: { + description: { + key: 'browser.chatTools.allowedDomains', + value: localize( + 'browser.chatTools.allowedDomains', + 'When non-empty, integrated browser chat tools (`#workbench.browser.enableChatTools#`) may only open, navigate to, or interact with pages whose host matches an entry. Supports wildcards such as `*.example.com` (matches `example.com` and any subdomain). Entries like `localhost` and `127.0.0.1` are supported. File URLs and pages without a host always pass. An empty array means no extra restriction. This applies only to browser chat tools; it does not change the fetch tool or other agent network settings (`#chat.agent.allowedNetworkDomains#`, `#chat.agent.networkFilter#`).' + ) + } + } + }, } } }); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts index c967ba47ca95a..bc69d3c0016bf 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts @@ -6,11 +6,13 @@ import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { BrowserViewUri } from '../../../../../platform/browserView/common/browserViewUri.js'; import { IInvokeFunctionResult, IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { IAgentNetworkFilterService } from '../../../../../platform/networkFilter/common/networkFilterService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IToolInvocation, IToolResult } from '../../../chat/common/tools/languageModelToolsService.js'; +import { BrowserChatToolsAllowedDomainsSettingId, isAllowedDomain } from '../../common/browserChatToolsAllowedDomains.js'; import { BrowserEditorInput } from '../../common/browserEditorInput.js'; import { BrowserViewSharingState, IBrowserViewWorkbenchService } from '../../common/browserView.js'; @@ -27,6 +29,41 @@ export function getSessionId(invocation: IToolInvocation): string { return invocation.context?.sessionResource?.toString() ?? ''; } +export function assertBrowserChatToolNavigationAllowed(url: string, configurationService: IConfigurationService): void { + const allowed = configurationService.getValue(BrowserChatToolsAllowedDomainsSettingId) ?? []; + if (isAllowedDomain(url, allowed)) { + return; + } + throw new Error(localize('browserChatTools.domainBlocked', 'Navigation blocked by BrowserChatToolsAllowedDomains policy.')); +} + +/** + * After a browser chat tool changes or relies on the current page URL, verify the page + * is still allowed. Returns an error result when restricted, otherwise `undefined`. + */ +export async function getBrowserChatToolDomainBlockedToolResult( + playwrightService: IPlaywrightService, + configurationService: IConfigurationService, + sessionId: string, + pageId: string, +): Promise { + const allowed = configurationService.getValue(BrowserChatToolsAllowedDomainsSettingId) ?? []; + if (!allowed.length) { + return undefined; + } + let url: string; + try { + url = await playwrightInvokeRaw(playwrightService, sessionId, pageId, (page) => page.url()); + } catch { + return errorResult(localize('browserChatTools.domainVerifyFailed', 'Could not verify the browser page URL for domain policy.')); + } + if (isAllowedDomain(url, allowed)) { + return undefined; + } + return errorResult(localize('browserChatTools.domainBlocked', 'Navigation blocked by BrowserChatToolsAllowedDomains policy.')); +} + + export interface FormatBrowserEditorLinesOptions { indent?: string; numbered?: boolean; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/clickBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/clickBrowserTool.ts index 5d4683ed864cc..aa3bca0097b5a 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/clickBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/clickBrowserTool.ts @@ -7,9 +7,10 @@ import type { CancellationToken } from '../../../../../base/common/cancellation. import { Codicon } from '../../../../../base/common/codicons.js'; import { escapeMarkdownSyntaxTokens, MarkdownString } from '../../../../../base/common/htmlContent.js'; import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; -import { createBrowserPageLink, DEFAULT_ELEMENT_LABEL, errorResult, getSessionId, playwrightInvoke } from './browserToolHelpers.js'; +import { createBrowserPageLink, DEFAULT_ELEMENT_LABEL, errorResult, getBrowserChatToolDomainBlockedToolResult, getSessionId, playwrightInvoke } from './browserToolHelpers.js'; import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; import { OpenPageToolId } from './openBrowserTool.js'; @@ -67,6 +68,7 @@ interface IClickBrowserToolParams { export class ClickBrowserTool implements IToolImpl { constructor( @IPlaywrightService private readonly playwrightService: IPlaywrightService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { } async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { @@ -99,6 +101,11 @@ export class ClickBrowserTool implements IToolImpl { return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); } + const blocked = await getBrowserChatToolDomainBlockedToolResult(this.playwrightService, this.configurationService, sessionId, params.pageId); + if (blocked) { + return blocked; + } + let selector = params.selector; if (params.ref) { selector = `aria-ref=${params.ref}`; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/dragElementTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/dragElementTool.ts index 9c887d1b97905..c71b8c855a0b3 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/dragElementTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/dragElementTool.ts @@ -7,9 +7,10 @@ import type { CancellationToken } from '../../../../../base/common/cancellation. import { Codicon } from '../../../../../base/common/codicons.js'; import { escapeMarkdownSyntaxTokens, MarkdownString } from '../../../../../base/common/htmlContent.js'; import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; -import { createBrowserPageLink, DEFAULT_ELEMENT_LABEL, errorResult, getSessionId, playwrightInvoke } from './browserToolHelpers.js'; +import { createBrowserPageLink, DEFAULT_ELEMENT_LABEL, errorResult, getBrowserChatToolDomainBlockedToolResult, getSessionId, playwrightInvoke } from './browserToolHelpers.js'; import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; import { OpenPageToolId } from './openBrowserTool.js'; @@ -71,6 +72,7 @@ interface IDragElementToolParams { export class DragElementTool implements IToolImpl { constructor( @IPlaywrightService private readonly playwrightService: IPlaywrightService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { } async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { @@ -92,6 +94,11 @@ export class DragElementTool implements IToolImpl { return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); } + const blocked = await getBrowserChatToolDomainBlockedToolResult(this.playwrightService, this.configurationService, sessionId, params.pageId); + if (blocked) { + return blocked; + } + let fromSelector = params.fromSelector; if (params.fromRef) { fromSelector = `aria-ref=${params.fromRef}`; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts index ce43e5d282049..ee8cedfcb3d30 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/handleDialogBrowserTool.ts @@ -7,9 +7,10 @@ import type { CancellationToken } from '../../../../../base/common/cancellation. import { Codicon } from '../../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; -import { createBrowserPageLink, errorResult, getSessionId } from './browserToolHelpers.js'; +import { createBrowserPageLink, errorResult, getBrowserChatToolDomainBlockedToolResult, getSessionId } from './browserToolHelpers.js'; import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; import { OpenPageToolId } from './openBrowserTool.js'; @@ -56,6 +57,7 @@ interface IHandleDialogBrowserToolParams { export class HandleDialogBrowserTool implements IToolImpl { constructor( @IPlaywrightService private readonly playwrightService: IPlaywrightService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { } async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { @@ -74,6 +76,11 @@ export class HandleDialogBrowserTool implements IToolImpl { return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); } + const blocked = await getBrowserChatToolDomainBlockedToolResult(this.playwrightService, this.configurationService, sessionId, params.pageId); + if (blocked) { + return blocked; + } + if (params.selectFiles !== undefined && (params.acceptModal !== undefined || params.promptText !== undefined)) { return errorResult(`Invalid parameters. 'selectFiles' cannot be used with 'acceptModal' or 'promptText'.`); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/hoverElementTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/hoverElementTool.ts index e3f037bd945f3..584961825c941 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/hoverElementTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/hoverElementTool.ts @@ -7,9 +7,10 @@ import type { CancellationToken } from '../../../../../base/common/cancellation. import { Codicon } from '../../../../../base/common/codicons.js'; import { escapeMarkdownSyntaxTokens, MarkdownString } from '../../../../../base/common/htmlContent.js'; import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; -import { createBrowserPageLink, DEFAULT_ELEMENT_LABEL, errorResult, getSessionId, playwrightInvoke } from './browserToolHelpers.js'; +import { createBrowserPageLink, DEFAULT_ELEMENT_LABEL, errorResult, getBrowserChatToolDomainBlockedToolResult, getSessionId, playwrightInvoke } from './browserToolHelpers.js'; import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; import { OpenPageToolId } from './openBrowserTool.js'; @@ -56,6 +57,7 @@ interface IHoverElementToolParams { export class HoverElementTool implements IToolImpl { constructor( @IPlaywrightService private readonly playwrightService: IPlaywrightService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { } async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { @@ -76,6 +78,11 @@ export class HoverElementTool implements IToolImpl { return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); } + const blocked = await getBrowserChatToolDomainBlockedToolResult(this.playwrightService, this.configurationService, sessionId, params.pageId); + if (blocked) { + return blocked; + } + let selector = params.selector; if (params.ref) { selector = `aria-ref=${params.ref}`; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts index e8e74e5427e98..1708b5d442975 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/navigateBrowserTool.ts @@ -8,10 +8,11 @@ import { Codicon } from '../../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; import { IAgentNetworkFilterService } from '../../../../../platform/networkFilter/common/networkFilterService.js'; -import { createBrowserPageLink, errorResult, getSessionId, playwrightInvoke } from './browserToolHelpers.js'; +import { assertBrowserChatToolNavigationAllowed, createBrowserPageLink, errorResult, getBrowserChatToolDomainBlockedToolResult, getSessionId, playwrightInvoke } from './browserToolHelpers.js'; import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; import { OpenPageToolId } from './openBrowserTool.js'; @@ -54,6 +55,7 @@ export class NavigateBrowserTool implements IToolImpl { constructor( @IPlaywrightService private readonly playwrightService: IPlaywrightService, @IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { } async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { @@ -92,6 +94,8 @@ export class NavigateBrowserTool implements IToolImpl { throw new Error(this.agentNetworkFilterService.formatError(uri)); } + assertBrowserChatToolNavigationAllowed(parsed.href, this.configurationService); + return { invocationMessage: new MarkdownString(localize('browser.navigate.invocation', "Navigating to {0} in {1}", parsed.href, link)), pastTenseMessage: new MarkdownString(localize('browser.navigate.past', "Navigated to {0} in {1}", parsed.href, link)), @@ -113,18 +117,31 @@ export class NavigateBrowserTool implements IToolImpl { return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); } + const blockedBefore = await getBrowserChatToolDomainBlockedToolResult(this.playwrightService, this.configurationService, sessionId, params.pageId); + if (blockedBefore) { + return blockedBefore; + } + + let result: IToolResult; switch (params.type) { case 'reload': - return playwrightInvoke(this.playwrightService, sessionId, params.pageId, (page) => page.reload({ waitUntil: 'domcontentloaded' })); + result = await playwrightInvoke(this.playwrightService, sessionId, params.pageId, (page) => page.reload({ waitUntil: 'domcontentloaded' })); + break; case 'back': - return playwrightInvoke(this.playwrightService, sessionId, params.pageId, (page) => page.goBack({ waitUntil: 'domcontentloaded' })); + result = await playwrightInvoke(this.playwrightService, sessionId, params.pageId, (page) => page.goBack({ waitUntil: 'domcontentloaded' })); + break; case 'forward': - return playwrightInvoke(this.playwrightService, sessionId, params.pageId, (page) => page.goForward({ waitUntil: 'domcontentloaded' })); + result = await playwrightInvoke(this.playwrightService, sessionId, params.pageId, (page) => page.goForward({ waitUntil: 'domcontentloaded' })); + break; default: { - return playwrightInvoke(this.playwrightService, sessionId, params.pageId, (page, url) => { + result = await playwrightInvoke(this.playwrightService, sessionId, params.pageId, (page, url) => { return page.goto(url, { waitUntil: 'domcontentloaded' }); }, params.url!); + break; } } + + const blocked = await getBrowserChatToolDomainBlockedToolResult(this.playwrightService, this.configurationService, sessionId, params.pageId); + return blocked ?? result; } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts index 6f43cd0db9003..5ae137f7fc597 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserTool.ts @@ -24,7 +24,7 @@ import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, import { BrowserViewSharingState, IBrowserViewWorkbenchService } from '../../common/browserView.js'; import { BrowserEditorInput } from '../../common/browserEditorInput.js'; import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; -import { createBrowserPageLink, findExistingPagesByHost, getExistingPagesResult, getSessionId } from './browserToolHelpers.js'; +import { assertBrowserChatToolNavigationAllowed, createBrowserPageLink, findExistingPagesByHost, getBrowserChatToolDomainBlockedToolResult, getExistingPagesResult, getSessionId } from './browserToolHelpers.js'; export const OpenPageToolId = 'open_browser_page'; @@ -96,6 +96,8 @@ export class OpenBrowserTool implements IToolImpl { throw new Error(this.agentNetworkFilterService.formatError(uri)); } + assertBrowserChatToolNavigationAllowed(parsed.href, this.configService); + return { invocationMessage: localize('browser.open.invocation', "Opening browser page at {0}", parsed.href), pastTenseMessage: localize('browser.open.past', "Opened browser page at {0}", parsed.href), @@ -271,11 +273,19 @@ export class OpenBrowserTool implements IToolImpl { } private async _openNewPage(sessionId: string, url: string): Promise { + assertBrowserChatToolNavigationAllowed(url, this.configService); const { pageId, summary } = await this.playwrightService.openPage(sessionId, url); + const blocked = await getBrowserChatToolDomainBlockedToolResult(this.playwrightService, this.configService, sessionId, pageId); + if (blocked) { + return blocked; + } return this._pageResult(pageId, summary, localize('browser.open.result', "Opened {0}", createBrowserPageLink(pageId))); } private async _shareExistingPage(sessionId: string, editor: BrowserEditorInput): Promise { + const pageUrl = editor.url || 'about:blank'; + assertBrowserChatToolNavigationAllowed(pageUrl, this.configService); + const model = await editor.resolve(); if (model.sharingState !== BrowserViewSharingState.Shared) { if (!(await model.setSharedWithAgent(true))) { @@ -284,6 +294,10 @@ export class OpenBrowserTool implements IToolImpl { } const summary = await this.playwrightService.getSummary(sessionId, editor.id); + const blocked = await getBrowserChatToolDomainBlockedToolResult(this.playwrightService, this.configService, sessionId, editor.id); + if (blocked) { + return blocked; + } return this._pageResult(editor.id, summary, localize('browser.open.sharedResult', "User shared {0}", createBrowserPageLink(editor.id))); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts index bb33f871c5534..aa58ac161f606 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/openBrowserToolNonAgentic.ts @@ -8,12 +8,13 @@ import { localize } from '../../../../../nls.js'; import { logBrowserOpen } from '../../../../../platform/browserView/common/browserViewTelemetry.js'; import { BrowserViewUri } from '../../../../../platform/browserView/common/browserViewUri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; import { IOpenBrowserToolParams, OpenBrowserToolData } from './openBrowserTool.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; -import { createBrowserPageLink, findExistingPagesByHost, getExistingPagesResult } from './browserToolHelpers.js'; +import { assertBrowserChatToolNavigationAllowed, createBrowserPageLink, findExistingPagesByHost, getExistingPagesResult } from './browserToolHelpers.js'; import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; export const OpenBrowserToolNonAgenticData: IToolData = { @@ -31,6 +32,7 @@ export class OpenBrowserToolNonAgentic implements IToolImpl { @ITelemetryService private readonly telemetryService: ITelemetryService, @IEditorService private readonly editorService: IEditorService, @IBrowserViewWorkbenchService private readonly browserViewService: IBrowserViewWorkbenchService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { } async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { @@ -44,6 +46,8 @@ export class OpenBrowserToolNonAgentic implements IToolImpl { throw new Error('You must provide a complete, valid URL.'); } + assertBrowserChatToolNavigationAllowed(parsed.href, this.configurationService); + return { invocationMessage: localize('browser.open.nonAgentic.invocation', "Opening browser page at {0}", parsed.href), pastTenseMessage: localize('browser.open.nonAgentic.past', "Opened browser page at {0}", parsed.href), diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/readBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/readBrowserTool.ts index 3b574b4fc6982..4361fe2efdffe 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/readBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/readBrowserTool.ts @@ -7,9 +7,10 @@ import type { CancellationToken } from '../../../../../base/common/cancellation. import { Codicon } from '../../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; -import { createBrowserPageLink, errorResult, getSessionId } from './browserToolHelpers.js'; +import { createBrowserPageLink, errorResult, getBrowserChatToolDomainBlockedToolResult, getSessionId } from './browserToolHelpers.js'; import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; import { OpenPageToolId } from './openBrowserTool.js'; @@ -40,6 +41,7 @@ interface IReadBrowserToolParams { export class ReadBrowserTool implements IToolImpl { constructor( @IPlaywrightService private readonly playwrightService: IPlaywrightService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { } async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { @@ -58,6 +60,11 @@ export class ReadBrowserTool implements IToolImpl { return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); } + const blocked = await getBrowserChatToolDomainBlockedToolResult(this.playwrightService, this.configurationService, sessionId, params.pageId); + if (blocked) { + return blocked; + } + const summary = await this.playwrightService.getSummary(sessionId, params.pageId); if (!summary) { return errorResult('No page summary available.'); diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts index d30bc0e9f24b7..2ca1c32dde905 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts @@ -7,9 +7,10 @@ import type { CancellationToken } from '../../../../../base/common/cancellation. import { Codicon } from '../../../../../base/common/codicons.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; -import { errorResult, getSessionId, invokeFunctionResultToToolResult } from './browserToolHelpers.js'; +import { errorResult, getBrowserChatToolDomainBlockedToolResult, getSessionId, invokeFunctionResultToToolResult } from './browserToolHelpers.js'; import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; import { OpenPageToolId } from './openBrowserTool.js'; @@ -56,6 +57,7 @@ interface IRunPlaywrightCodeToolParams { export class RunPlaywrightCodeTool implements IToolImpl { constructor( @IPlaywrightService private readonly playwrightService: IPlaywrightService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { } async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { @@ -91,9 +93,14 @@ export class RunPlaywrightCodeTool implements IToolImpl { // Resume waiting for a deferred execution if (params.deferredResultId) { + const blockedBeforeDefer = await getBrowserChatToolDomainBlockedToolResult(this.playwrightService, this.configurationService, sessionId, params.pageId); + if (blockedBeforeDefer) { + return blockedBeforeDefer; + } try { const result = await this.playwrightService.waitForDeferredResult(sessionId, params.deferredResultId, params.timeoutMs ?? 5_000); - return invokeFunctionResultToToolResult(result); + const blockedAfter = await getBrowserChatToolDomainBlockedToolResult(this.playwrightService, this.configurationService, sessionId, params.pageId); + return blockedAfter ?? invokeFunctionResultToToolResult(result); } catch (e) { return errorResult(e instanceof Error ? e.message : String(e)); } @@ -103,6 +110,11 @@ export class RunPlaywrightCodeTool implements IToolImpl { return errorResult('Either "code" or "deferredResultId" must be provided.'); } + const blockedBefore = await getBrowserChatToolDomainBlockedToolResult(this.playwrightService, this.configurationService, sessionId, params.pageId); + if (blockedBefore) { + return blockedBefore; + } + let result; try { result = await this.playwrightService.invokeFunction(sessionId, params.pageId, `async (page) => { ${params.code} }`, undefined, params.timeoutMs ?? 5_000); @@ -111,6 +123,11 @@ export class RunPlaywrightCodeTool implements IToolImpl { return errorResult(`Code execution failed: ${message}`); } + const blockedAfter = await getBrowserChatToolDomainBlockedToolResult(this.playwrightService, this.configurationService, sessionId, params.pageId); + if (blockedAfter) { + return blockedAfter; + } + return invokeFunctionResultToToolResult(result, params.code.trim()); } } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts index c7db62ec3c857..bcbf619895395 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/screenshotBrowserTool.ts @@ -10,10 +10,11 @@ import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; import { IBrowserViewWorkbenchService } from '../../common/browserView.js'; -import { errorResult, getSessionId, playwrightInvokeRaw } from './browserToolHelpers.js'; +import { errorResult, getBrowserChatToolDomainBlockedToolResult, getSessionId, playwrightInvokeRaw } from './browserToolHelpers.js'; import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; import { OpenPageToolId } from './openBrowserTool.js'; import { ReadBrowserToolData } from './readBrowserTool.js'; @@ -66,6 +67,7 @@ export class ScreenshotBrowserTool implements IToolImpl { constructor( @IBrowserViewWorkbenchService private readonly browserViewWorkbenchService: IBrowserViewWorkbenchService, @IPlaywrightService private readonly playwrightService: IPlaywrightService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { } async prepareToolInvocation(_context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { @@ -91,6 +93,11 @@ export class ScreenshotBrowserTool implements IToolImpl { return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); } + const blocked = await getBrowserChatToolDomainBlockedToolResult(this.playwrightService, this.configurationService, sessionId, params.pageId); + if (blocked) { + return blocked; + } + let selector = params.selector; if (params.ref) { selector = `aria-ref=${params.ref}`; diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts index 3c06bacf0e092..22924606b22e2 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/typeBrowserTool.ts @@ -10,9 +10,10 @@ import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; -import { createBrowserPageLink, errorResult, getSessionId, playwrightInvoke } from './browserToolHelpers.js'; +import { createBrowserPageLink, errorResult, getBrowserChatToolDomainBlockedToolResult, getSessionId, playwrightInvoke } from './browserToolHelpers.js'; import { BrowserChatToolReferenceName } from '../../common/browserChatToolReferenceNames.js'; import { OpenPageToolId } from './openBrowserTool.js'; @@ -69,6 +70,7 @@ interface ITypeBrowserToolParams { export class TypeBrowserTool implements IToolImpl { constructor( @IPlaywrightService private readonly playwrightService: IPlaywrightService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { } async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { @@ -112,6 +114,11 @@ export class TypeBrowserTool implements IToolImpl { return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); } + const blocked = await getBrowserChatToolDomainBlockedToolResult(this.playwrightService, this.configurationService, sessionId, params.pageId); + if (blocked) { + return blocked; + } + let selector = params.selector; if (params.ref) { selector = `aria-ref=${params.ref}`; diff --git a/src/vs/workbench/contrib/browserView/test/common/browserChatToolsAllowedDomains.test.ts b/src/vs/workbench/contrib/browserView/test/common/browserChatToolsAllowedDomains.test.ts new file mode 100644 index 0000000000000..95dba48b0cc82 --- /dev/null +++ b/src/vs/workbench/contrib/browserView/test/common/browserChatToolsAllowedDomains.test.ts @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { isAllowedDomain } from '../../common/browserChatToolsAllowedDomains.js'; + +suite('browserChatToolsAllowedDomains', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('empty allowlist does not restrict', () => { + assert.strictEqual(isAllowedDomain('https://example.com', []), true); + }); + + test('non-empty allowlist requires a host match', () => { + assert.strictEqual(isAllowedDomain('https://example.com', ['localhost']), false); + assert.strictEqual(isAllowedDomain('https://localhost/foo', ['localhost']), true); + }); + + test('file URLs always pass when allowlist is non-empty', () => { + assert.strictEqual(isAllowedDomain('file:///tmp/x', ['localhost']), true); + }); + + test('wildcard patterns match apex and subdomains', () => { + const allowed = ['*.example.com']; + assert.strictEqual(isAllowedDomain('https://example.com/', allowed), true); + assert.strictEqual(isAllowedDomain('https://app.example.com/', allowed), true); + assert.strictEqual(isAllowedDomain('https://notexample.com/', allowed), false); + }); +});