Skip to content
Closed
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
2 changes: 2 additions & 0 deletions src/vs/code/electron-main/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ import { McpGatewayChannel } from '../../platform/mcp/node/mcpGatewayChannel.js'
import { IWebContentExtractorService } from '../../platform/webContentExtractor/common/webContentExtractor.js';
import { NativeWebContentExtractorService } from '../../platform/webContentExtractor/electron-main/webContentExtractorService.js';
import { AgentNetworkFilterService, IAgentNetworkFilterService } from '../../platform/networkFilter/common/networkFilterService.js';
import { ITerminalSandboxService, NullTerminalSandboxService } from '../../platform/sandbox/common/terminalSandboxService.js';
import { CrossAppIPCService, ICrossAppIPCService } from '../../platform/crossAppIpc/electron-main/crossAppIpcService.js';
import ErrorTelemetry from '../../platform/telemetry/electron-main/errorTelemetry.js';

Expand Down Expand Up @@ -1109,6 +1110,7 @@ export class CodeApplication extends Disposable {
services.set(IMeteredConnectionService, meteredConnectionService);

// Web Contents Extractor
services.set(ITerminalSandboxService, new SyncDescriptor(NullTerminalSandboxService));
services.set(IAgentNetworkFilterService, new SyncDescriptor(AgentNetworkFilterService, undefined, true));
services.set(IWebContentExtractorService, new SyncDescriptor(NativeWebContentExtractorService, undefined, false /* proxied to other processes */));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ import { IMeteredConnectionService } from '../../../platform/meteredConnection/c
import { MeteredConnectionChannelClient, METERED_CONNECTION_CHANNEL } from '../../../platform/meteredConnection/common/meteredConnectionIpc.js';
import { PlaywrightChannel } from '../../../platform/browserView/node/playwrightChannel.js';
import { AgentNetworkFilterService } from '../../../platform/networkFilter/common/networkFilterService.js';
import { NullTerminalSandboxService } from '../../../platform/sandbox/common/terminalSandboxService.js';
import { ILocalGitService } from '../../../platform/git/common/localGitService.js';
import { LocalGitService } from '../../../platform/git/node/localGitService.js';

Expand Down Expand Up @@ -486,7 +487,7 @@ class SharedProcessMain extends Disposable implements IClientConnectionFilter {
this.server.registerChannel('sharedWebContentExtractor', webContentExtractorChannel);

// Playwright
const agentNetworkFilterService = this._register(new AgentNetworkFilterService(accessor.get(IConfigurationService)));
const agentNetworkFilterService = this._register(new AgentNetworkFilterService(accessor.get(IConfigurationService), new NullTerminalSandboxService()));
const playwrightChannel = this._register(new PlaywrightChannel(this.server, accessor.get(IMainProcessService), accessor.get(ILogService), agentNetworkFilterService));
this.server.registerChannel('playwright', playwrightChannel);

Expand Down
27 changes: 23 additions & 4 deletions src/vs/platform/networkFilter/common/networkFilterService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { URI } from '../../../base/common/uri.js';
import { localize } from '../../../nls.js';
import { IConfigurationService } from '../../configuration/common/configuration.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import { ITerminalSandboxService } from '../../sandbox/common/terminalSandboxService.js';
import { extractDomainFromUri, isDomainAllowed } from './domainMatcher.js';
import { AgentNetworkDomainSettingId } from './settings.js';

Expand All @@ -19,8 +20,9 @@ export const IAgentNetworkFilterService = createDecorator<IAgentNetworkFilterSer
* Service that filters network requests made by agent tools (fetch tool,
* integrated browser) based on the configured allowed/denied domain lists.
*
* Filtering is only active when the `chat.agent.networkFilter` setting is
* enabled. When both domain lists are empty, all domains are denied.
* Filtering is active when the `chat.agent.networkFilter` setting is enabled,
* or when the terminal sandbox service reports that sandboxing is enabled.
* When both domain lists are empty, all domains are denied.
* When a domain appears on the denied list it is always blocked, even if it
* also matches an entry on the allowed list.
*/
Expand Down Expand Up @@ -52,6 +54,7 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo
readonly _serviceBrand: undefined;

private enabled = false;
private terminalSandboxEnabled = false;
private allowedPatterns: string[] = [];
private deniedPatterns: string[] = [];
private readonly domainCache = new LRUCache<string, boolean>(100);
Expand All @@ -61,9 +64,11 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo

constructor(
@IConfigurationService private readonly configurationService: IConfigurationService,
@ITerminalSandboxService private readonly terminalSandboxService: ITerminalSandboxService,
) {
super();
this.readConfiguration();
void this.updateTerminalSandboxEnabled();

this._register(this.configurationService.onDidChangeConfiguration(e => {
if (
Expand All @@ -73,19 +78,33 @@ export class AgentNetworkFilterService extends Disposable implements IAgentNetwo
) {
this.readConfiguration();
this.onDidChangeEmitter.fire();
} else {
void this.updateTerminalSandboxEnabled();
}
}));
}

private readConfiguration(): void {
this.enabled = this.configurationService.getValue<boolean>(AgentNetworkDomainSettingId.NetworkFilter) ?? false;
const networkFilterEnabled = this.configurationService.getValue<boolean>(AgentNetworkDomainSettingId.NetworkFilter) ?? false;

this.enabled = networkFilterEnabled || this.terminalSandboxEnabled;
this.allowedPatterns = this.configurationService.getValue<string[]>(AgentNetworkDomainSettingId.AllowedNetworkDomains) ?? [];
this.deniedPatterns = this.configurationService.getValue<string[]>(AgentNetworkDomainSettingId.DeniedNetworkDomains) ?? [];
this.domainCache.clear();
}

private async updateTerminalSandboxEnabled(): Promise<void> {
const enabled = await this.terminalSandboxService.isEnabled();
if (this.terminalSandboxEnabled === enabled) {
return;
}
this.terminalSandboxEnabled = enabled;
this.readConfiguration();
this.onDidChangeEmitter.fire();
}

isUriAllowed(uri: URI): boolean {
// When the network filter is disabled, allow all requests.
// When domain filtering is inactive, allow all requests.
if (!this.enabled) {
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,20 @@ import { ConfigurationTarget } from '../../../configuration/common/configuration
import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js';
import { AgentNetworkFilterService } from '../../common/networkFilterService.js';
import { AgentNetworkDomainSettingId } from '../../common/settings.js';
import { ITerminalSandboxService, NullTerminalSandboxService } from '../../../sandbox/common/terminalSandboxService.js';

suite('AgentNetworkFilterService', () => {

let disposables: DisposableStore;
let configService: TestConfigurationService;
let terminalSandboxEnabled: boolean;
let terminalSandboxService: Pick<ITerminalSandboxService, 'isEnabled'>;

setup(() => {
disposables = new DisposableStore();
configService = new TestConfigurationService();
terminalSandboxEnabled = false;
terminalSandboxService = Object.assign(new NullTerminalSandboxService(), { isEnabled: async () => terminalSandboxEnabled });
configService.setUserConfiguration(AgentNetworkDomainSettingId.NetworkFilter, true);
configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, []);
configService.setUserConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains, []);
Expand All @@ -31,9 +36,10 @@ suite('AgentNetworkFilterService', () => {

ensureNoDisposablesAreLeakedInTestSuite();

function createService(): AgentNetworkFilterService {
const service = new AgentNetworkFilterService(configService);
async function createService(): Promise<AgentNetworkFilterService> {
const service = new AgentNetworkFilterService(configService, terminalSandboxService);
disposables.add(service);
await Promise.resolve();
return service;
}

Expand All @@ -46,65 +52,76 @@ suite('AgentNetworkFilterService', () => {
});
}

test('allows all domains when filter is disabled', () => {
test('allows all domains when filter is disabled', async () => {
configService.setUserConfiguration(AgentNetworkDomainSettingId.NetworkFilter, false);
const service = createService();
const service = await createService();
assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), true);
assert.strictEqual(service.isUriAllowed(URI.parse('https://anything.test')), true);
});

test('denies all domains when both lists are empty', () => {
const service = createService();
test('network filter disabled with sandbox enabled activates filtering', async () => {
configService.setUserConfiguration(AgentNetworkDomainSettingId.NetworkFilter, false);
terminalSandboxEnabled = true;
configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['example.com']);

const service = await createService();

assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), true);
assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com')), false);
});

test('denies all domains when both lists are empty', async () => {
const service = await createService();
assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), false);
assert.strictEqual(service.isUriAllowed(URI.parse('https://anything.test')), false);
});

test('blocks denied domains', () => {
test('blocks denied domains', async () => {
configService.setUserConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains, ['evil.com']);
const service = createService();
const service = await createService();
assert.strictEqual(service.isUriAllowed(URI.parse('https://evil.com')), false);
assert.strictEqual(service.isUriAllowed(URI.parse('https://good.com')), true);
});

test('restricts to allowed domains', () => {
test('restricts to allowed domains', async () => {
configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['example.com']);
const service = createService();
const service = await createService();
assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), true);
assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com')), false);
});

test('denied takes precedence over allowed', () => {
test('denied takes precedence over allowed', async () => {
configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['*.com']);
configService.setUserConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains, ['evil.com']);
const service = createService();
const service = await createService();
assert.strictEqual(service.isUriAllowed(URI.parse('https://safe.com')), true);
assert.strictEqual(service.isUriAllowed(URI.parse('https://evil.com')), false);
});

suite('isUriAllowed', () => {

test('allows file URIs', () => {
const service = createService();
test('allows file URIs', async () => {
const service = await createService();
configService.setUserConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains, ['*']);
assert.strictEqual(service.isUriAllowed(URI.file('/tmp/test.txt')), true);
});

test('allows URIs without authority', () => {
const service = createService();
test('allows URIs without authority', async () => {
const service = await createService();
configService.setUserConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains, ['*']);
assert.strictEqual(service.isUriAllowed(URI.from({ scheme: 'untitled', path: 'Untitled-1' })), true);
});

test('checks domain for http/https URIs', () => {
test('checks domain for http/https URIs', async () => {
configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['example.com']);
const service = createService();
const service = await createService();
assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com/page')), true);
assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com/page')), false);
});
});

test('fires onDidChange when configuration changes', async () => {
const service = createService();
const service = await createService();
let fired = false;
disposables.add(service.onDidChange(() => { fired = true; }));

Expand All @@ -116,12 +133,30 @@ suite('AgentNetworkFilterService', () => {

test('updates filtering after configuration change', async () => {
configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['example.com']);
const service = createService();
const service = await createService();
assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), true);

configService.setUserConfiguration(AgentNetworkDomainSettingId.DeniedNetworkDomains, ['example.com']);
fireConfigChange(AgentNetworkDomainSettingId.DeniedNetworkDomains);

assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), false);
});

test('terminal sandbox enablement change fires onDidChange and updates filtering', async () => {
configService.setUserConfiguration(AgentNetworkDomainSettingId.NetworkFilter, false);
configService.setUserConfiguration(AgentNetworkDomainSettingId.AllowedNetworkDomains, ['example.com']);
const service = await createService();
assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com')), true);

let fired = false;
disposables.add(service.onDidChange(() => { fired = true; }));

terminalSandboxEnabled = true;
fireConfigChange('chat.agent.sandbox.enabled');
await Promise.resolve();

assert.strictEqual(fired, true);
assert.strictEqual(service.isUriAllowed(URI.parse('https://example.com')), true);
assert.strictEqual(service.isUriAllowed(URI.parse('https://other.com')), false);
});
});
127 changes: 127 additions & 0 deletions src/vs/platform/sandbox/common/terminalSandboxService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

import { CancellationToken } from '../../../base/common/cancellation.js';
import { Event } from '../../../base/common/event.js';
import { URI } from '../../../base/common/uri.js';
import { OperatingSystem, OS } from '../../../base/common/platform.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
import { TerminalCapability } from '../../terminal/common/capabilities/capabilities.js';

export const ITerminalSandboxService = createDecorator<ITerminalSandboxService>('terminalSandboxService');

export interface ITerminalSandboxResolvedNetworkDomains {
allowedDomains: string[];
deniedDomains: string[];
}

export const enum TerminalSandboxPrerequisiteCheck {
Config = 'config',
Dependencies = 'dependencies',
}

export interface ITerminalSandboxPrerequisiteCheckResult {
enabled: boolean;
sandboxConfigPath: string | undefined;
failedCheck: TerminalSandboxPrerequisiteCheck | undefined;
missingDependencies?: string[];
}

export interface ITerminalSandboxWrapResult {
command: string;
isSandboxWrapped: boolean;
blockedDomains?: string[];
deniedDomains?: string[];
requiresUnsandboxConfirmation?: boolean;
}

/**
* Abstraction over terminal operations needed by the install flow.
* Provided by the browser-layer caller so the common-layer service
* does not import browser types directly.
*/
export interface ISandboxDependencyInstallTerminal {
sendText(text: string, addNewLine?: boolean): Promise<void>;
focus(): void;
capabilities: {
get(id: TerminalCapability.CommandDetection): { onCommandFinished: Event<{ exitCode: number | undefined }> } | undefined;
onDidAddCapability: Event<{ id: TerminalCapability }>;
};
onDidInputData: Event<string>;
onDisposed: Event<unknown>;
}

export interface ISandboxDependencyInstallOptions {
/**
* Creates or obtains a terminal for running the install command.
*/
createTerminal(): Promise<ISandboxDependencyInstallTerminal>;
/**
* Focuses the terminal for password entry.
*/
focusTerminal(terminal: ISandboxDependencyInstallTerminal): Promise<void>;
}

export interface ISandboxDependencyInstallResult {
exitCode: number | undefined;
}

export interface ITerminalSandboxService {
readonly _serviceBrand: undefined;
isEnabled(): Promise<boolean>;
getOS(): Promise<OperatingSystem>;
checkForSandboxingPrereqs(forceRefresh?: boolean): Promise<ITerminalSandboxPrerequisiteCheckResult>;
wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string): ITerminalSandboxWrapResult;
getSandboxConfigPath(forceRefresh?: boolean): Promise<string | undefined>;
getTempDir(): URI | undefined;
setNeedsForceUpdateConfigFile(): void;
getResolvedNetworkDomains(): ITerminalSandboxResolvedNetworkDomains;
getMissingSandboxDependencies(): Promise<string[]>;
installMissingSandboxDependencies(missingDependencies: string[], sessionResource: URI | undefined, token: CancellationToken, options: ISandboxDependencyInstallOptions): Promise<ISandboxDependencyInstallResult>;
}

export class NullTerminalSandboxService implements ITerminalSandboxService {
readonly _serviceBrand: undefined;

async isEnabled(): Promise<boolean> {
return false;
}

async getOS(): Promise<OperatingSystem> {
return OS;
}

async checkForSandboxingPrereqs(): Promise<ITerminalSandboxPrerequisiteCheckResult> {
return { enabled: false, sandboxConfigPath: undefined, failedCheck: undefined };
}

wrapCommand(command: string): ITerminalSandboxWrapResult {
return { command, isSandboxWrapped: false };
}

async getSandboxConfigPath(): Promise<string | undefined> {
return undefined;
}

getTempDir(): URI | undefined {
return undefined;
}

setNeedsForceUpdateConfigFile(): void {
// No-op.
}

getResolvedNetworkDomains(): ITerminalSandboxResolvedNetworkDomains {
return { allowedDomains: [], deniedDomains: [] };
}

async getMissingSandboxDependencies(): Promise<string[]> {
return [];
}

async installMissingSandboxDependencies(): Promise<ISandboxDependencyInstallResult> {
return { exitCode: undefined };
}
}
Loading
Loading