Path: blob/main/src/vs/workbench/contrib/mcp/common/mcpSandboxService.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { VSBuffer } from '../../../../base/common/buffer.js';6import { Disposable } from '../../../../base/common/lifecycle.js';7import { FileAccess } from '../../../../base/common/network.js';8import { dirname, posix, win32 } from '../../../../base/common/path.js';9import { OperatingSystem, OS } from '../../../../base/common/platform.js';10import { URI } from '../../../../base/common/uri.js';11import { generateUuid } from '../../../../base/common/uuid.js';12import { localize } from '../../../../nls.js';13import { ConfigurationTarget, ConfigurationTargetToString } from '../../../../platform/configuration/common/configuration.js';14import { IEnvironmentService } from '../../../../platform/environment/common/environment.js';15import { IFileService } from '../../../../platform/files/common/files.js';16import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';17import { ILogService } from '../../../../platform/log/common/log.js';18import { IMcpResourceScannerService, McpResourceTarget } from '../../../../platform/mcp/common/mcpResourceScannerService.js';19import { IRemoteAgentEnvironment } from '../../../../platform/remote/common/remoteAgentEnvironment.js';20import { IRemoteAgentService } from '../../../services/remote/common/remoteAgentService.js';21import { IMcpSandboxConfiguration } from '../../../../platform/mcp/common/mcpPlatformTypes.js';22import { IMcpPotentialSandboxBlock, McpServerDefinition, McpServerLaunch, McpServerTransportStdio, McpServerTransportType } from './mcpTypes.js';232425export const IMcpSandboxService = createDecorator<IMcpSandboxService>('mcpSandboxService');2627export interface IMcpSandboxService {28readonly _serviceBrand: undefined;29launchInSandboxIfEnabled(serverDef: McpServerDefinition, launch: McpServerLaunch, remoteAuthority: string | undefined, configTarget: ConfigurationTarget): Promise<McpServerLaunch>;30isEnabled(serverDef: McpServerDefinition, serverLabel?: string): Promise<boolean>;31getSandboxConfigSuggestionMessage(serverLabel: string, potentialBlocks: readonly IMcpPotentialSandboxBlock[], existingSandboxConfig?: IMcpSandboxConfiguration): SandboxConfigSuggestionResult | undefined;32applySandboxConfigSuggestion(serverDef: McpServerDefinition, mcpResource: URI, configTarget: ConfigurationTarget, potentialBlocks: readonly IMcpPotentialSandboxBlock[], suggestedSandboxConfig?: IMcpSandboxConfiguration): Promise<boolean>;33}3435type SandboxConfigSuggestions = {36allowWrite: readonly string[];37allowedDomains: readonly string[];38};3940type SandboxConfigSuggestionResult = {41message: string;42sandboxConfig: IMcpSandboxConfiguration;43};4445type SandboxLaunchDetails = {46execPath: string | undefined;47srtPath: string | undefined;48rgPath: string | undefined;49sandboxConfigPath: string | undefined;50tempDir: URI | undefined;51};5253export class McpSandboxService extends Disposable implements IMcpSandboxService {54readonly _serviceBrand: undefined;5556private _sandboxSettingsId: string | undefined;57private _remoteEnvDetailsPromise: Promise<IRemoteAgentEnvironment | null>;58private readonly _defaultAllowedDomains: readonly string[] = ['registry.npmjs.org']; // Default allowed domains that are commonly needed for MCP servers, even if the user doesn't specify them in their sandbox config59private _defaultAllowWritePaths: string[] = ['~/.npm'];60private _sandboxConfigPerConfigurationTarget: Map<string, string> = new Map();6162constructor(63@IFileService private readonly _fileService: IFileService,64@IEnvironmentService private readonly _environmentService: IEnvironmentService,65@ILogService private readonly _logService: ILogService,66@IMcpResourceScannerService private readonly _mcpResourceScannerService: IMcpResourceScannerService,67@IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService,68) {69super();70this._sandboxSettingsId = generateUuid();71this._remoteEnvDetailsPromise = this._remoteAgentService.getEnvironment();7273}7475public async isEnabled(serverDef: McpServerDefinition, remoteAuthority?: string): Promise<boolean> {76const os = await this._getOperatingSystem(remoteAuthority);77if (os === OperatingSystem.Windows) {78return false;79}80return !!serverDef.sandboxEnabled;81}8283public async launchInSandboxIfEnabled(serverDef: McpServerDefinition, launch: McpServerLaunch, remoteAuthority: string | undefined, configTarget: ConfigurationTarget): Promise<McpServerLaunch> {84if (launch.type !== McpServerTransportType.Stdio) {85return launch;86}87if (await this.isEnabled(serverDef, remoteAuthority)) {88this._logService.trace(`McpSandboxService: Launching with config target ${configTarget}`);89const launchDetails = await this._resolveSandboxLaunchDetails(configTarget, remoteAuthority, launch.sandbox, launch.cwd);90const quotedCommand = this._quoteShellArgument(launch.command);91const quotedArgs = launch.args.map(arg => this._quoteShellArgument(arg));92const sandboxArgs = this._getSandboxCommandArgs(quotedCommand, quotedArgs, launchDetails.sandboxConfigPath);93const sandboxEnv = await this._getSandboxEnvVariables(launch.env, launchDetails.tempDir, launchDetails.rgPath, remoteAuthority);94if (launchDetails.srtPath) {95if (launchDetails.execPath) {96return {97...launch,98command: launchDetails.execPath,99args: [launchDetails.srtPath, ...sandboxArgs],100env: sandboxEnv,101type: McpServerTransportType.Stdio,102};103} else {104return {105...launch,106command: launchDetails.srtPath,107args: sandboxArgs,108env: sandboxEnv,109type: McpServerTransportType.Stdio,110};111}112}113if (!launchDetails.execPath) {114this._logService.warn('McpSandboxService: execPath is unavailable, launching without sandbox runtime wrapper');115}116this._logService.debug(`McpSandboxService: launch details for server ${serverDef.label} - command: ${launch.command}, args: ${launch.args.join(' ')}`);117}118return launch;119}120121public getSandboxConfigSuggestionMessage(serverLabel: string, potentialBlocks: readonly IMcpPotentialSandboxBlock[], existingSandboxConfig?: IMcpSandboxConfiguration): SandboxConfigSuggestionResult | undefined {122const suggestions = this._getSandboxConfigSuggestions(potentialBlocks, existingSandboxConfig);123if (!suggestions) {124return undefined;125}126127const allowWriteList = suggestions.allowWrite;128const allowedDomainsList = suggestions.allowedDomains;129const suggestionLines: string[] = [];130131if (allowedDomainsList.length) {132const shown = allowedDomainsList.map(domain => `"${domain}"`).join(', ');133suggestionLines.push(localize('mcpSandboxSuggestion.allowedDomains', "Add to `sandbox.network.allowedDomains`: {0}", shown));134}135136if (allowWriteList.length) {137const shown = allowWriteList.map(path => `"${path}"`).join(', ');138suggestionLines.push(localize('mcpSandboxSuggestion.allowWrite', "Add to `sandbox.filesystem.allowWrite`: {0}", shown));139}140141const sandboxConfig: IMcpSandboxConfiguration = {};142if (allowedDomainsList.length) {143sandboxConfig.network = { allowedDomains: [...allowedDomainsList] };144}145if (allowWriteList.length) {146sandboxConfig.filesystem = { allowWrite: [...allowWriteList] };147}148149return {150message: localize(151'mcpSandboxSuggestion.message',152"The MCP server {0} reported potential sandbox blocks. VS Code found possible sandbox configuration updates:\n{1}",153serverLabel,154suggestionLines.join('\n')155),156sandboxConfig,157};158}159160public async applySandboxConfigSuggestion(serverDef: McpServerDefinition, mcpResource: URI, configTarget: ConfigurationTarget, potentialBlocks: readonly IMcpPotentialSandboxBlock[], suggestedSandboxConfig?: IMcpSandboxConfiguration): Promise<boolean> {161const scanTarget = this._toMcpResourceTarget(configTarget);162let didChange = false;163164await this._mcpResourceScannerService.updateSandboxConfig(data => {165const existingSandbox = data.sandbox;166const suggestedAllowedDomains = suggestedSandboxConfig?.network?.allowedDomains ?? [];167const suggestedAllowWrite = suggestedSandboxConfig?.filesystem?.allowWrite ?? [];168169const currentAllowedDomains = new Set(existingSandbox?.network?.allowedDomains ?? []);170for (const domain of suggestedAllowedDomains) {171if (domain && !currentAllowedDomains.has(domain)) {172currentAllowedDomains.add(domain);173}174}175176const currentAllowWrite = new Set(existingSandbox?.filesystem?.allowWrite ?? []);177for (const path of suggestedAllowWrite) {178if (path && !currentAllowWrite.has(path)) {179currentAllowWrite.add(path);180}181}182183if (suggestedAllowedDomains.length === 0 && suggestedAllowWrite.length === 0) {184return data;185}186187didChange = true;188const nextSandboxConfig: IMcpSandboxConfiguration = {};189if (currentAllowedDomains.size > 0) {190nextSandboxConfig.network = {191...existingSandbox?.network,192allowedDomains: [...currentAllowedDomains]193};194}195if (currentAllowWrite.size > 0) {196nextSandboxConfig.filesystem = {197...existingSandbox?.filesystem,198allowWrite: [...currentAllowWrite],199};200}201return {202...data,203sandbox: nextSandboxConfig,204};205}, mcpResource, scanTarget);206207return didChange;208}209210private _getSandboxConfigSuggestions(potentialBlocks: readonly IMcpPotentialSandboxBlock[], existingSandboxConfig?: IMcpSandboxConfiguration): SandboxConfigSuggestions | undefined {211if (!potentialBlocks.length) {212return undefined;213}214215const allowWrite = new Set<string>();216const allowedDomains = new Set<string>();217const existingAllowWrite = new Set(existingSandboxConfig?.filesystem?.allowWrite ?? []);218const existingAllowedDomains = new Set(existingSandboxConfig?.network?.allowedDomains ?? []);219220for (const block of potentialBlocks) {221if (block.kind === 'network' && block.host && !existingAllowedDomains.has(block.host)) {222allowedDomains.add(block.host);223}224225if (block.kind === 'filesystem' && block.path && !existingAllowWrite.has(block.path)) {226allowWrite.add(block.path);227}228}229230if (!allowWrite.size && !allowedDomains.size) {231return undefined;232}233234return {235allowWrite: [...allowWrite],236allowedDomains: [...allowedDomains],237};238}239240private _toMcpResourceTarget(configTarget: ConfigurationTarget): McpResourceTarget {241switch (configTarget) {242case ConfigurationTarget.USER:243case ConfigurationTarget.USER_LOCAL:244case ConfigurationTarget.USER_REMOTE:245return ConfigurationTarget.USER;246case ConfigurationTarget.WORKSPACE:247return ConfigurationTarget.WORKSPACE;248case ConfigurationTarget.WORKSPACE_FOLDER:249return ConfigurationTarget.WORKSPACE_FOLDER;250default:251return ConfigurationTarget.USER;252}253}254255private async _resolveSandboxLaunchDetails(configTarget: ConfigurationTarget, remoteAuthority?: string, sandboxConfig?: IMcpSandboxConfiguration, launchCwd?: string): Promise<SandboxLaunchDetails> {256const os = await this._getOperatingSystem(remoteAuthority);257if (os === OperatingSystem.Windows) {258return { execPath: undefined, srtPath: undefined, rgPath: undefined, sandboxConfigPath: undefined, tempDir: undefined };259}260261const appRoot = await this._getAppRoot(remoteAuthority);262const execPath = await this._getExecPath(os, appRoot, remoteAuthority);263const tempDir = await this._getTempDir(remoteAuthority);264const srtPath = this._pathJoin(os, appRoot, 'node_modules', '@anthropic-ai', 'sandbox-runtime', 'dist', 'cli.js');265const rgPath = this._pathJoin(os, appRoot, 'node_modules', '@vscode', 'ripgrep', 'bin', 'rg');266const sandboxConfigPath = tempDir ? await this._updateSandboxConfig(tempDir, configTarget, sandboxConfig, launchCwd) : undefined;267this._logService.debug(`McpSandboxService: Updated sandbox config path: ${sandboxConfigPath}`);268return { execPath, srtPath, rgPath, sandboxConfigPath, tempDir };269}270271private async _getExecPath(os: OperatingSystem, appRoot: string, remoteAuthority?: string): Promise<string | undefined> {272if (remoteAuthority) {273return this._pathJoin(os, appRoot, 'node');274}275return undefined; // Use Electron executable as the default exec path for local development, which will run the sandbox runtime wrapper with Electron in node mode. For remote, we need to specify the node executable to ensure it runs with Node.js.276}277278private async _getSandboxEnvVariables(baseEnv: McpServerTransportStdio['env'], tempDir: URI | undefined, rgPath: string | undefined, remoteAuthority?: string): Promise<McpServerTransportStdio['env']> {279let env: McpServerTransportStdio['env'] = { ...baseEnv };280if (tempDir) {281env = { ...env, TMPDIR: tempDir.path, SRT_DEBUG: 'true', NODE_USE_ENV_PROXY: '1' };282}283if (rgPath) {284env = { ...env, PATH: env['PATH'] ? `${env['PATH']}${await this._getPathDelimiter(remoteAuthority)}${dirname(rgPath)}` : dirname(rgPath) };285}286if (!remoteAuthority) {287// Add any remote-specific environment variables here288env = { ...env, ELECTRON_RUN_AS_NODE: '1' };289}290// Ensure VSCODE_INSPECTOR_OPTIONS is not inherited by the sandboxed process, as it can cause issues with sandboxing.291env['VSCODE_INSPECTOR_OPTIONS'] = null;292return env;293}294295private _getSandboxCommandArgs(command: string, args: readonly string[], sandboxConfigPath: string | undefined): string[] {296const result: string[] = [];297if (sandboxConfigPath) {298result.push('--settings', sandboxConfigPath);299result.push('--');300}301result.push(command, ...args);302return result;303}304305private async _getRemoteEnv(remoteAuthority?: string): Promise<IRemoteAgentEnvironment | null> {306if (!remoteAuthority) {307return null;308}309return this._remoteEnvDetailsPromise;310}311312private async _getOperatingSystem(remoteAuthority?: string): Promise<OperatingSystem> {313const remoteEnv = await this._getRemoteEnv(remoteAuthority);314if (remoteEnv) {315return remoteEnv.os;316}317return OS;318}319320private async _getAppRoot(remoteAuthority?: string): Promise<string> {321const remoteEnv = await this._getRemoteEnv(remoteAuthority);322if (remoteEnv) {323return remoteEnv.appRoot.path;324}325return dirname(FileAccess.asFileUri('').path);326}327328private async _getTempDir(remoteAuthority?: string): Promise<URI | undefined> {329const remoteEnv = await this._getRemoteEnv(remoteAuthority);330if (remoteEnv) {331return remoteEnv.tmpDir;332}333const environmentService = this._environmentService as IEnvironmentService & { tmpDir?: URI };334const tempDir = environmentService.tmpDir;335if (!tempDir) {336this._logService.warn('McpSandboxService: Cannot create sandbox settings file because no tmpDir is available in this environment');337}338return tempDir;339}340341private async _updateSandboxConfig(tempDir: URI, configTarget: ConfigurationTarget, sandboxConfig?: IMcpSandboxConfiguration, launchCwd?: string): Promise<string> {342const normalizedSandboxConfig = this._withDefaultSandboxConfig(sandboxConfig, launchCwd);343let configFileUri: URI;344const configTargetKey = ConfigurationTargetToString(configTarget);345if (this._sandboxConfigPerConfigurationTarget.has(configTargetKey)) {346configFileUri = URI.parse(this._sandboxConfigPerConfigurationTarget.get(configTargetKey)!);347} else {348configFileUri = URI.joinPath(tempDir, `vscode-${configTargetKey}-mcp-sandbox-settings-${this._sandboxSettingsId}.json`);349this._sandboxConfigPerConfigurationTarget.set(configTargetKey, configFileUri.toString());350}351await this._fileService.createFile(configFileUri, VSBuffer.fromString(JSON.stringify(normalizedSandboxConfig, null, '\t')), { overwrite: true });352return configFileUri.path;353}354355// this method merges the default allowWrite paths and allowedDomains with the ones provided in the sandbox config, to ensure that the default necessary paths and domains are always included in the sandbox config used for launching,356// even if they are not explicitly specified in the config provided by the user or the MCP server config.357private _withDefaultSandboxConfig(sandboxConfig?: IMcpSandboxConfiguration, launchCwd?: string): IMcpSandboxConfiguration {358const mergedAllowWrite = new Set(sandboxConfig?.filesystem?.allowWrite ?? []);359for (const defaultAllowWrite of this._getDefaultAllowWrite(launchCwd ? [launchCwd] : undefined)) {360if (defaultAllowWrite) {361mergedAllowWrite.add(defaultAllowWrite);362}363}364365const mergedAllowedDomains = new Set(sandboxConfig?.network?.allowedDomains ?? []);366for (const defaultAllowedDomain of this._defaultAllowedDomains) {367if (defaultAllowedDomain) {368mergedAllowedDomains.add(defaultAllowedDomain);369}370}371372return {373...sandboxConfig,374network: {375allowedDomains: [...mergedAllowedDomains],376deniedDomains: sandboxConfig?.network?.deniedDomains ?? [],377},378filesystem: {379allowWrite: [...mergedAllowWrite],380denyRead: sandboxConfig?.filesystem?.denyRead ?? [],381denyWrite: sandboxConfig?.filesystem?.denyWrite ?? [],382},383};384}385386private _getDefaultAllowWrite(directories?: string[]): readonly string[] {387for (const launchCwd of directories ?? []) {388const trimmed = launchCwd.trim();389if (trimmed) {390this._defaultAllowWritePaths.push(trimmed);391}392}393return this._defaultAllowWritePaths;394}395396private _pathJoin = (os: OperatingSystem, ...segments: string[]) => {397const path = os === OperatingSystem.Windows ? win32 : posix;398return path.join(...segments);399};400401private _getPathDelimiter = async (remoteAuthority?: string) => {402const os = await this._getOperatingSystem(remoteAuthority);403return os === OperatingSystem.Windows ? win32.delimiter : posix.delimiter;404};405406private _quoteShellArgument(value: string): string {407return `'${value.replace(/'/g, `'\\''`)}'`;408}409410}411412413