Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
15 changes: 15 additions & 0 deletions build/lib/policies/policyData.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions src/vs/platform/networkFilter/common/domainMatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
18 changes: 17 additions & 1 deletion src/vs/platform/networkFilter/test/common/domainMatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {

Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -482,6 +483,30 @@ Registry.as<IConfigurationRegistry>(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#`).'
)
}
}
},
}
}
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -27,6 +29,41 @@ export function getSessionId(invocation: IToolInvocation): string {
return invocation.context?.sessionResource?.toString() ?? '<default>';
}

export function assertBrowserChatToolNavigationAllowed(url: string, configurationService: IConfigurationService): void {
const allowed = configurationService.getValue<string[]>(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<IToolResult | undefined> {
const allowed = configurationService.getValue<string[]>(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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<IPreparedToolInvocation | undefined> {
Expand Down Expand Up @@ -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}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<IPreparedToolInvocation | undefined> {
Expand All @@ -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}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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<IPreparedToolInvocation | undefined> {
Expand All @@ -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'.`);
}
Expand Down
Loading