Skip to content
Merged
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: 3 additions & 3 deletions build/lib/policies/policyData.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"localization": {
"description": {
"key": "chat.agent.allowedNetworkDomains",
"value": "Allowed domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. When `#chat.agent.sandbox.allowNetwork#` is enabled, all domains are allowed. Supports wildcards like `*.example.com`. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see `#chat.agent.deniedNetworkDomains#`) take precedence."
"value": "Allowed domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. When `#chat.agent.sandbox.allowNetwork#` is enabled, all domains are allowed. Supports wildcards like `*.example.com`. When both allowed and denied lists are empty, all domains are blocked. Denied domains (see `#chat.agent.deniedNetworkDomains#`) take precedence.\n\nChanges may not take full effect until VS Code is restarted."
}
},
"type": "array",
Expand All @@ -76,7 +76,7 @@
"localization": {
"description": {
"key": "chat.agent.deniedNetworkDomains",
"value": "Denied domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. This does not apply when `#chat.agent.sandbox.allowNetwork#` is enabled. Takes precedence over `#chat.agent.allowedNetworkDomains#`. Supports wildcards like `*.example.com`."
"value": "Denied domains for network access by agent tools (fetch tool, integrated browser). Applies when `#chat.agent.networkFilter#` or `#chat.agent.sandbox.enabled#` is enabled. This does not apply when `#chat.agent.sandbox.allowNetwork#` is enabled. Takes precedence over `#chat.agent.allowedNetworkDomains#`. Supports wildcards like `*.example.com`.\n\nChanges may not take full effect until VS Code is restarted."
}
},
"type": "array",
Expand Down Expand Up @@ -106,7 +106,7 @@
"localization": {
"description": {
"key": "chat.agent.networkFilter",
"value": "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to `#chat.agent.allowedNetworkDomains#` and `#chat.agent.deniedNetworkDomains#`. Domain filtering is also applied to those tools when `#chat.agent.sandbox.enabled#` is enabled."
"value": "When enabled, network access by agent tools (fetch tool, integrated browser) is restricted according to `#chat.agent.allowedNetworkDomains#` and `#chat.agent.deniedNetworkDomains#`. Domain filtering is also applied to those tools when `#chat.agent.sandbox.enabled#` is enabled.\n\nChanges may not take full effect until VS Code is restarted."
}
},
"type": "boolean",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,25 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
assert.match(output, /Page ID:/, `Expected output to contain "Page ID:", got: ${output}`);
});

(vscode.env.remoteName ? test.skip : test)('Agent storage is shared between API and tool pages and isolated from persistent storage', async function () {
(vscode.env.remoteName ? test.skip : test)('Agent storage is shared, filtered, and isolated from persistent storage', async function () {
this.timeout(60_000);

const token = `${Date.now()}-${Math.random()}`;
let agentReceivedCookie: string | undefined;
let globalReceivedCookie: string | undefined;
let workspaceReceivedCookie: string | undefined;
const server = http.createServer((request, response) => {
let agentProbeReceived = false;
let workspaceProbeReceived = false;
const server = http.createServer();
await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, resolve);
});

const address = server.address();
assert.ok(address && typeof address !== 'string');
const port = address.port;
server.on('request', (request, response) => {
if (request.url === '/set-global') {
response.setHeader('Set-Cookie', `vscode-browser-global-smoke=${token}; Path=/; SameSite=Lax`);
response.end('<title>global-cookie-set</title>');
Expand All @@ -107,13 +118,13 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {

if (request.url === '/set-workspace') {
response.setHeader('Set-Cookie', `vscode-browser-workspace-smoke=${token}; Path=/; SameSite=Lax`);
response.end('<title>workspace-cookie-set</title>');
response.end(`<title>workspace-cookie-set</title><img src="http://localhost:${port}/workspace-probe">`);
return;
}

if (request.url === '/set-agent') {
response.setHeader('Set-Cookie', `vscode-browser-agent-smoke=${token}; Path=/; SameSite=Lax`);
response.end('<title>agent-cookie-set</title>');
response.end(`<title>agent-cookie-set</title><img src="http://localhost:${port}/agent-probe">`);
return;
}

Expand All @@ -135,54 +146,63 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
return;
}

response.end('<title>unexpected-request</title>');
});
if (request.url === '/agent-probe') {
agentProbeReceived = true;
response.end();
return;
}

await new Promise<void>((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
if (request.url === '/workspace-probe') {
workspaceProbeReceived = true;
response.end();
return;
}

const address = server.address();
assert.ok(address && typeof address !== 'string');
response.end('<title>unexpected-request</title>');
});
const browserConfig = vscode.workspace.getConfiguration('workbench.browser');
const agentConfig = vscode.workspace.getConfiguration('chat.agent');

try {
await agentConfig.update('allowedNetworkDomains', ['*'], vscode.ConfigurationTarget.Global);
await agentConfig.update('deniedNetworkDomains', ['localhost'], vscode.ConfigurationTarget.Global);
await agentConfig.update('networkFilter', true, vscode.ConfigurationTarget.Global);

await browserConfig.update('dataStorage', 'global', vscode.ConfigurationTarget.Global);
const globalSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/set-global`);
const globalSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${port}/set-global`);
for (let i = 0; i < 100 && !globalSetTab.title.startsWith('global-cookie-set'); i++) {
await new Promise(resolve => setTimeout(resolve, 50));
}
assert.ok(globalSetTab.title.startsWith('global-cookie-set'), `Expected Global page to load, got title "${globalSetTab.title}"`);

await browserConfig.update('dataStorage', 'workspace', vscode.ConfigurationTarget.Global);
const workspaceSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/set-workspace`);
for (let i = 0; i < 100 && !workspaceSetTab.title.startsWith('workspace-cookie-set'); i++) {
const workspaceSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${port}/set-workspace`);
for (let i = 0; i < 100 && (!workspaceSetTab.title.startsWith('workspace-cookie-set') || !workspaceProbeReceived); i++) {
await new Promise(resolve => setTimeout(resolve, 50));
}
assert.ok(workspaceSetTab.title.startsWith('workspace-cookie-set'), `Expected Workspace page to load, got title "${workspaceSetTab.title}"`);

await browserConfig.update('dataStorage', 'agent', vscode.ConfigurationTarget.Global);
const agentSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/set-agent`);
const agentSetTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${port}/set-agent`);

for (let i = 0; i < 100 && !agentSetTab.title.startsWith('agent-cookie-set'); i++) {
await new Promise(resolve => setTimeout(resolve, 50));
}
assert.ok(agentSetTab.title.startsWith('agent-cookie-set'), `Expected Agent page to load, got title "${agentSetTab.title}"`);

const output = await invokeTool('open_browser_page', {
url: `http://127.0.0.1:${address.port}/check-agent`,
url: `http://127.0.0.1:${port}/check-agent`,
forceNew: true,
});

await browserConfig.update('dataStorage', 'global', vscode.ConfigurationTarget.Global);
const globalCheckTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/check-global`);
const globalCheckTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${port}/check-global`);
for (let i = 0; i < 100 && !globalCheckTab.title.startsWith('global-cookie-checked'); i++) {
await new Promise(resolve => setTimeout(resolve, 50));
}

await browserConfig.update('dataStorage', 'workspace', vscode.ConfigurationTarget.Global);
const workspaceCheckTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${address.port}/check-workspace`);
const workspaceCheckTab = await vscode.window.openBrowserTab(`http://127.0.0.1:${port}/check-workspace`);
for (let i = 0; i < 100 && !workspaceCheckTab.title.startsWith('workspace-cookie-checked'); i++) {
await new Promise(resolve => setTimeout(resolve, 50));
}
Expand All @@ -192,26 +212,33 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
agentSharedCookie: agentReceivedCookie?.includes(`vscode-browser-agent-smoke=${token}`) === true,
agentReceivedGlobalCookie: agentReceivedCookie?.includes(`vscode-browser-global-smoke=${token}`) === true,
agentReceivedWorkspaceCookie: agentReceivedCookie?.includes(`vscode-browser-workspace-smoke=${token}`) === true,
agentBlockedRequest: !agentProbeReceived,
globalLoaded: globalCheckTab.title.startsWith('global-cookie-checked'),
globalSharedCookie: globalReceivedCookie?.includes(`vscode-browser-global-smoke=${token}`) === true,
globalReceivedAgentCookie: globalReceivedCookie?.includes(`vscode-browser-agent-smoke=${token}`) === true,
workspaceLoaded: workspaceCheckTab.title.startsWith('workspace-cookie-checked'),
workspaceSharedCookie: workspaceReceivedCookie?.includes(`vscode-browser-workspace-smoke=${token}`) === true,
workspaceReceivedAgentCookie: workspaceReceivedCookie?.includes(`vscode-browser-agent-smoke=${token}`) === true,
workspaceAllowedRequest: workspaceProbeReceived,
}, {
opened: true,
agentSharedCookie: true,
agentReceivedGlobalCookie: false,
agentReceivedWorkspaceCookie: false,
agentBlockedRequest: true,
globalLoaded: true,
globalSharedCookie: true,
globalReceivedAgentCookie: false,
workspaceLoaded: true,
workspaceSharedCookie: true,
workspaceReceivedAgentCookie: false,
workspaceAllowedRequest: true,
});
} finally {
await browserConfig.update('dataStorage', undefined, vscode.ConfigurationTarget.Global);
await agentConfig.update('networkFilter', undefined, vscode.ConfigurationTarget.Global);
await agentConfig.update('allowedNetworkDomains', undefined, vscode.ConfigurationTarget.Global);
await agentConfig.update('deniedNetworkDomains', undefined, vscode.ConfigurationTarget.Global);
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()));
}
});
Expand Down
4 changes: 4 additions & 0 deletions src/vs/platform/browserView/common/browserView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,10 @@ export function isInMemoryStorageScope(scope: BrowserViewStorageScope): boolean
return scope === BrowserViewStorageScope.Ephemeral || scope === BrowserViewStorageScope.Agent;
}

export function isBrowserViewStorageScopeShareableWithAgent(scope: BrowserViewStorageScope, networkFilteringEnabled: boolean): boolean {
return !networkFilteringEnabled || scope === BrowserViewStorageScope.Agent;
}

/** Selects an existing browser context by ID or resolves one from storage options. */
export type BrowserViewSessionSelector = string | IBrowserViewSessionOptions;

Expand Down
47 changes: 44 additions & 3 deletions src/vs/platform/browserView/electron-main/browserSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { BrowserSessionRemote, IBrowserSessionRemote } from './browserSessionRem
import { FileAccess, Schemas } from '../../../base/common/network.js';
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
import { localize } from '../../../nls.js';
import { IAgentNetworkFilterService } from '../../networkFilter/common/networkFilterService.js';

/**
* Holds an Electron session along with its storage scope and unique browser
Expand Down Expand Up @@ -66,8 +67,8 @@ export class BrowserSession {
* Cleans up stale {@link _byId} entries when the Electron session
* they point to is garbage-collected.
*/
private static readonly _finalizer = new FinalizationRegistry<string>((id) => {
BrowserSession._byId.delete(id);
private static readonly _finalizer = new FinalizationRegistry<string>(id => {
this._byId.delete(id);
});

/**
Expand Down Expand Up @@ -115,6 +116,18 @@ export class BrowserSession {
return ids;
}

/** Update network filtering on all live browser sessions. */
static updateNetworkFiltering(): void {
for (const [id, ref] of BrowserSession._byId) {
const browserSession = ref.deref();
if (browserSession) {
browserSession.updateNetworkFilter();
} else {
BrowserSession._byId.delete(id);
}
}
}

/**
* Get or create the singleton global-scope session.
*/
Expand Down Expand Up @@ -229,6 +242,7 @@ export class BrowserSession {
private readonly _history: BrowserSessionHistory;
private readonly _remote: BrowserSessionRemote;
private readonly _permissions: BrowserSessionPermissions;
private _networkFilterEnabled = false;

/**
* @deprecated Don't use this directly. Create sessions via the static factory methods.
Expand All @@ -244,11 +258,13 @@ export class BrowserSession {
readonly electronSession: Electron.Session,
/** Resolved storage scope. */
readonly storageScope: BrowserViewStorageScope,
@IAgentNetworkFilterService private readonly agentNetworkFilterService: IAgentNetworkFilterService,
) {
this._trust = new BrowserSessionTrust(this);
this._history = new BrowserSessionHistory(this);
this._remote = new BrowserSessionRemote(this);
this._permissions = new BrowserSessionPermissions(this);
this.updateNetworkFilter();
this.configure();
BrowserSession.knownSessions.add(electronSession);
BrowserSession._bySession.set(electronSession, this);
Expand Down Expand Up @@ -289,7 +305,32 @@ export class BrowserSession {
}

/**
* Apply the permission policy and preload scripts to the session.
* Dynamically apply network filtering to Agent sessions.
*/
private updateNetworkFilter(): void {
if (this.storageScope !== BrowserViewStorageScope.Agent) {
return;
}

const enabled = this.agentNetworkFilterService.isEnabled();
if (this._networkFilterEnabled === enabled) {
return;
}
this._networkFilterEnabled = enabled;
this.electronSession.webRequest.onBeforeRequest(enabled ? (details, callback) => {
let uri: URI;
try {
uri = URI.parse(details.url, true);
} catch {
callback({ cancel: true });
return;
}
callback({ cancel: !this.agentNetworkFilterService.isUriAllowed(uri) });
} : null);
}

/**
* Apply permissions, protocols, and preload scripts to the session.
*/
private configure(): void {
this._permissions.configure(this.electronSession);
Expand Down
31 changes: 31 additions & 0 deletions src/vs/platform/browserView/electron-main/browserViewGroup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I
if (this._isActive) {
return;
}
this._validateAgentGroupStorageScope();
this._isActive = true;

const views = await this.browserViewMainService.getBrowserViews(this.targetContext.host.windowId);
Expand Down Expand Up @@ -158,6 +159,9 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I
if (!view) {
throw new Error(`Browser view ${viewId} not found`);
}
if (this.filter.audience?.type === 'agent') {
this.browserViewMainService.validateAgentAccess(view);
}
this.views.set(view.id, view);
this.knownContextIds.add(view.session.id);

Expand Down Expand Up @@ -274,9 +278,17 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I
}

async createTarget(url: string, browserContextId?: string): Promise<ICDPTarget> {
this._validateAgentGroupStorageScope();
if (browserContextId && !this.knownContextIds.has(browserContextId)) {
throw new Error(`Unknown browser context ${browserContextId}`);
}
if (browserContextId && this.filter.audience?.type === 'agent') {
const browserSession = BrowserSession.get(browserContextId);
if (!browserSession) {
throw new Error(`Browser context ${browserContextId} no longer exists`);
}
this.browserViewMainService.validateAgentStorageScope(browserSession.storageScope);
}

const target = await this.browserViewMainService.createTarget(url, {
...this.targetContext,
Expand Down Expand Up @@ -317,6 +329,7 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I
}

async createBrowserContext(): Promise<string> {
this._validateAgentGroupStorageScope();
const contextId = generateUuid();
const sessionSelector = this.targetContext.session;
const usesAgentStorage = typeof sessionSelector === 'string'
Expand All @@ -330,6 +343,24 @@ export class BrowserViewGroup extends Disposable implements ICDPBrowserTarget, I
return browserSession.id;
}

private _validateAgentGroupStorageScope(): void {
if (this.filter.audience?.type === 'agent') {
this.browserViewMainService.validateAgentStorageScope(this._getTargetStorageScope());
}
}

private _getTargetStorageScope(): BrowserViewStorageScope {
if (typeof this.targetContext.session === 'string') {
const browserSession = BrowserSession.get(this.targetContext.session);
if (!browserSession) {
throw new Error(`Browser session ${this.targetContext.session} not found`);
}
return browserSession.storageScope;
}

return this.targetContext.session.scope;
}

async disposeBrowserContext(browserContextId: string): Promise<void> {
if (!this.ownedContextIds.has(browserContextId)) {
throw new Error('Can only dispose browser contexts created by this group');
Expand Down
Loading