From a2d7b9e13bdbe52233ea06b2ca6bc69a81083772 Mon Sep 17 00:00:00 2001 From: dileepyavan <52841896+dileepyavan@users.noreply.github.com> Date: Mon, 30 Mar 2026 06:33:04 -0700 Subject: [PATCH] [Sandbox] Notify user to run out of sandbox if the domain is not included in allowedDomains. (#306121) * Rename sandbox setting to chat.agent.sandbox (#303421) Rename the top-level sandbox setting from `chat.tools.terminal.sandbox.enabled` to `chat.agent.sandbox` to reflect that sandboxing is a general agent concept, not terminal-specific. - Update setting ID value to `chat.agent.sandbox` - Update description to be more general - Deprecate old `chat.tools.terminal.sandbox.enabled` setting - Update telemetry event name Fixes #303421 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * updating terminal sandbox to agent sandbox * removed allowTrustedDomains * correcting the settings keys for sandboxing * correcting the settings keys for sandboxing * Explicit notification for blocked domains before running the command * Fix terminal sandbox follow-ups * main merge * fixing tests * Update src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Revert "Update src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts" This reverts commit b956dfa71937bb6e471fc96cddcb9a0ed55b5788. * removing local files --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../chat.runInTerminal.test.ts | 57 +++--- .../commandLineRewriter.ts | 3 + .../commandLineSandboxRewriter.ts | 9 +- .../browser/tools/runInTerminalTool.ts | 54 ++++- .../common/terminalSandboxService.ts | 184 ++++++++++++++++- .../sandboxedCommandLinePresenter.test.ts | 5 +- .../browser/terminalSandboxService.test.ts | 188 +++++++++++++++++- .../commandLineSandboxRewriter.test.ts | 22 +- .../runInTerminalTool.test.ts | 42 +++- 9 files changed, 508 insertions(+), 56 deletions(-) diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts index 7eb79d13413..6fcfb7f48c7 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/chat.runInTerminal.test.ts @@ -315,30 +315,22 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { assert.strictEqual(output.trim(), marker); }); - test('network requests are blocked', async function () { + test('network requests to allowlisted domains succeed in sandbox', async function () { this.timeout(60000); - const output = await invokeRunInTerminal('curl -s --max-time 5 https://example.com'); - - // Without shell integration, exit code is unavailable and - // curl produces no sandbox-specific error strings, so the - // sandbox analyzer may not trigger. - const acceptable = [ - [ - 'Command failed while running in sandboxed mode. If the command failed due to sandboxing:', - `- If it would be reasonable to extend the sandbox rules, work with the user to update allowWrite for file system access problems in ${sandboxFileSystemSetting}, or to add required domains to chat.agent.sandboxNetwork.allowedDomains.`, - '- Otherwise, immediately retry the command with requestUnsandboxedExecution=true. Do NOT ask the user \u2014 setting this flag automatically shows a confirmation prompt to the user.', - '', - 'Here is the output of the command:', - '', - '', - '', + const configuration = vscode.workspace.getConfiguration(); + await configuration.update('chat.agent.sandboxNetwork.allowedDomains', ['example.com'], vscode.ConfigurationTarget.Global); + try { + const output = await invokeRunInTerminal('curl -s --max-time 5 https://example.com'); + const trimmed = output.trim(); + const acceptable = [ 'Command produced no output', - 'Command exited with code 56', - ].join('\n'), - ...(!hasShellIntegration ? ['Command produced no output'] : []), - ]; - assert.ok(acceptable.includes(output.trim()), `Unexpected output: ${JSON.stringify(output.trim())}`); + 'Example Domain', + ]; + assert.ok(acceptable.some(value => trimmed.includes(value) || trimmed === value), `Unexpected output: ${JSON.stringify(trimmed)}`); + } finally { + await configuration.update('chat.agent.sandboxNetwork.allowedDomains', undefined, vscode.ConfigurationTarget.Global); + } }); test('requestUnsandboxedExecution preserves sandbox $TMPDIR', async function () { @@ -349,7 +341,11 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { // Step 1: Write a sentinel file into the sandbox-provided $TMPDIR. const writeOutput = await invokeRunInTerminal(`echo ${marker} > "$TMPDIR/${sentinelName}" && echo ${marker}`); - assert.strictEqual(writeOutput.trim(), marker); + const writeAcceptable = [ + marker, + ...(!hasShellIntegration ? ['Command produced no output'] : []), + ]; + assert.ok(writeAcceptable.includes(writeOutput.trim()), `Unexpected output: ${JSON.stringify(writeOutput.trim())}`); // Step 2: Retry with requestUnsandboxedExecution=true while sandbox // stays enabled. The tool should preserve $TMPDIR from the sandbox so @@ -359,7 +355,10 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { requestUnsandboxedExecution: true, requestUnsandboxedExecutionReason: 'Need to verify $TMPDIR persists on unsandboxed retry', }); - assert.strictEqual(retryOutput.trim(), marker); + const trimmed = retryOutput.trim(); + assert.ok(trimmed.startsWith('Note: The tool simplified the command to'), `Unexpected output: ${JSON.stringify(trimmed)}`); + assert.ok(trimmed.includes(`cat "$TMPDIR/${sentinelName}"`), `Unexpected output: ${JSON.stringify(trimmed)}`); + assert.ok(trimmed.endsWith(marker), `Unexpected output: ${JSON.stringify(trimmed)}`); }); test('cannot write to /tmp', async function () { @@ -422,6 +421,18 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string { assert.strictEqual(output.trim(), marker); }); + + test('non-allowlisted domains trigger unsandboxed confirmation flow', async function () { + this.timeout(60000); + + const marker = `SANDBOX_DOMAIN_${Date.now()}`; + const output = await invokeRunInTerminal(`echo https://example.net >/dev/null && echo "${marker}" > /tmp/${marker}.txt && cat /tmp/${marker}.txt && rm /tmp/${marker}.txt`); + + const trimmed = output.trim(); + assert.ok(trimmed.startsWith('Note: The tool simplified the command to'), `Unexpected output: ${JSON.stringify(trimmed)}`); + assert.ok(trimmed.includes('https://example.net'), `Unexpected output: ${JSON.stringify(trimmed)}`); + assert.ok(trimmed.endsWith(marker), `Unexpected output: ${JSON.stringify(trimmed)}`); + }); }); } }); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts index 2c1dbc6acc0..fd91cf08e43 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineRewriter.ts @@ -26,4 +26,7 @@ export interface ICommandLineRewriterResult { //for scenarios where we want to show a different command in the chat UI than what is actually run in the terminal forDisplay?: string; isSandboxWrapped?: boolean; + requiresUnsandboxConfirmation?: boolean; + blockedDomains?: string[]; + deniedDomains?: string[]; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts index ed8c4407ed1..625a9608bdc 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts @@ -22,10 +22,13 @@ export class CommandLineSandboxRewriter extends Disposable implements ICommandLi const wrappedCommand = this._sandboxService.wrapCommand(options.commandLine, options.requestUnsandboxedExecution); return { - rewritten: wrappedCommand, - reasoning: 'Wrapped command for sandbox execution', + rewritten: wrappedCommand.command, + reasoning: wrappedCommand.requiresUnsandboxConfirmation ? 'Switched command to unsandboxed execution because the command includes a domain that is not in the sandbox allowlist' : 'Wrapped command for sandbox execution', forDisplay: options.commandLine, // show the command that is passed as input (after prior rewrites like cd prefix stripping) - isSandboxWrapped: true, + isSandboxWrapped: wrappedCommand.isSandboxWrapped, + requiresUnsandboxConfirmation: wrappedCommand.requiresUnsandboxConfirmation, + blockedDomains: wrappedCommand.blockedDomains, + deniedDomains: wrappedCommand.deniedDomains, }; } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index a1bc09bf2e6..9cb2883731b 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -562,7 +562,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { ]); const language = os === OperatingSystem.Windows ? 'pwsh' : 'sh'; const isTerminalSandboxEnabled = sandboxPrereqs.enabled; - const requiresUnsandboxConfirmation = isTerminalSandboxEnabled && args.requestUnsandboxedExecution === true; + const explicitUnsandboxRequest = isTerminalSandboxEnabled && args.requestUnsandboxedExecution === true; + let requiresUnsandboxConfirmation = explicitUnsandboxRequest; + let requestUnsandboxedExecutionReason = explicitUnsandboxRequest ? args.requestUnsandboxedExecutionReason : undefined; + let blockedDomains: string[] | undefined; const missingDependencies = sandboxPrereqs.failedCheck === TerminalSandboxPrerequisiteCheck.Dependencies && sandboxPrereqs.missingDependencies?.length ? sandboxPrereqs.missingDependencies @@ -588,6 +591,15 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { forDisplayCommand = rewriteResult.forDisplay ?? forDisplayCommand; if (rewriteResult.isSandboxWrapped) { isSandboxWrapped = true; + } else if (rewriteResult.isSandboxWrapped === false) { + isSandboxWrapped = false; + } + if (rewriteResult.requiresUnsandboxConfirmation) { + requiresUnsandboxConfirmation = true; + } + if (rewriteResult.blockedDomains?.length) { + blockedDomains = rewriteResult.blockedDomains; + requestUnsandboxedExecutionReason = this._getBlockedDomainReason(rewriteResult.blockedDomains, rewriteResult.deniedDomains); } this._logService.info(`RunInTerminalTool: Command rewritten by ${rewriter.constructor.name}: ${rewriteResult.reasoning}`); } @@ -607,7 +619,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { language, isBackground: args.isBackground, requestUnsandboxedExecution: requiresUnsandboxConfirmation, - requestUnsandboxedExecutionReason: args.requestUnsandboxedExecutionReason, + requestUnsandboxedExecutionReason, missingSandboxDependencies: missingDependencies, }; @@ -803,9 +815,13 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } if (requiresUnsandboxConfirmation) { - confirmationTitle = args.isBackground - ? localize('runInTerminal.unsandboxed.background', "Run `{0}` command outside the [sandbox]({1}) in background?", shellType, TERMINAL_SANDBOX_DOCUMENTATION_URL) - : localize('runInTerminal.unsandboxed', "Run `{0}` command outside the [sandbox]({1})?", shellType, TERMINAL_SANDBOX_DOCUMENTATION_URL); + confirmationTitle = blockedDomains?.length + ? (args.isBackground + ? localize('runInTerminal.unsandboxed.domain.background', "Run `{0}` command outside the [sandbox]({1}) in background to access {2}?", shellType, TERMINAL_SANDBOX_DOCUMENTATION_URL, this._formatBlockedDomainsForTitle(blockedDomains)) + : localize('runInTerminal.unsandboxed.domain', "Run `{0}` command outside the [sandbox]({1}) to access {2}?", shellType, TERMINAL_SANDBOX_DOCUMENTATION_URL, this._formatBlockedDomainsForTitle(blockedDomains))) + : (args.isBackground + ? localize('runInTerminal.unsandboxed.background', "Run `{0}` command outside the [sandbox]({1}) in background?", shellType, TERMINAL_SANDBOX_DOCUMENTATION_URL) + : localize('runInTerminal.unsandboxed', "Run `{0}` command outside the [sandbox]({1})?", shellType, TERMINAL_SANDBOX_DOCUMENTATION_URL)); } // If forceConfirmationReason is set, always show confirmation regardless of auto-approval @@ -816,7 +832,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { "Explanation: {0}\n\nGoal: {1}\n\nReason for leaving the sandbox: {2}", args.explanation, args.goal, - args.requestUnsandboxedExecutionReason || localize('runInTerminal.unsandboxed.confirmationMessage.defaultReason', "The model indicated that this command needs unsandboxed access.") + requestUnsandboxedExecutionReason || localize('runInTerminal.unsandboxed.confirmationMessage.defaultReason', "The model indicated that this command needs unsandboxed access.") )) : new MarkdownString(localize('runInTerminal.confirmationMessage', "Explanation: {0}\n\nGoal: {1}", args.explanation, args.goal)); const confirmationMessages = shouldShowConfirmation ? { @@ -848,6 +864,32 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { }; } + private _formatBlockedDomainsForTitle(blockedDomains: string[]): string { + if (blockedDomains.length === 1) { + return `\`${blockedDomains[0]}\``; + } + return localize('runInTerminal.unsandboxed.domain.summary', "`{0}` and {1} more domains", blockedDomains[0], blockedDomains.length - 1); + } + + private _getBlockedDomainReason(blockedDomains: string[], deniedDomains: string[] = []): string { + if (deniedDomains.length === blockedDomains.length && deniedDomains.length > 0) { + if (blockedDomains.length === 1) { + return localize('runInTerminal.unsandboxed.domain.reason.denied.single', "This command accesses {0}, which is blocked by chat.agent.sandboxNetwork.deniedDomains.", blockedDomains[0]); + } + return localize('runInTerminal.unsandboxed.domain.reason.denied.multi', "This command accesses {0} and {1} more domains that are blocked by chat.agent.sandboxNetwork.deniedDomains.", blockedDomains[0], blockedDomains.length - 1); + } + if (deniedDomains.length > 0) { + if (blockedDomains.length === 1) { + return localize('runInTerminal.unsandboxed.domain.reason.mixed.single', "This command accesses {0}, which is blocked by chat.agent.sandboxNetwork.deniedDomains or not added to chat.agent.sandboxNetwork.allowedDomains.", blockedDomains[0]); + } + return localize('runInTerminal.unsandboxed.domain.reason.mixed.multi', "This command accesses {0} and {1} more domains that are blocked by chat.agent.sandboxNetwork.deniedDomains or not added to chat.agent.sandboxNetwork.allowedDomains.", blockedDomains[0], blockedDomains.length - 1); + } + if (blockedDomains.length === 1) { + return localize('runInTerminal.unsandboxed.domain.reason.single', "This command accesses {0}, which is not permitted by the current chat.agent.sandboxNetwork configuration.", blockedDomains[0]); + } + return localize('runInTerminal.unsandboxed.domain.reason.multi', "This command accesses {0} and {1} more domains that are not permitted by the current chat.agent.sandboxNetwork configuration.", blockedDomains[0], blockedDomains.length - 1); + } + /** * Returns true if the chat session's permission level (Autopilot/Bypass Approvals) * auto-approves all tool calls, unless enterprise policy restricts it. diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts index 28d13709cde..596b98e1410 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts @@ -52,6 +52,14 @@ export interface ITerminalSandboxPrerequisiteCheckResult { 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 @@ -98,7 +106,7 @@ export interface ITerminalSandboxService { isEnabled(): Promise; getOS(): Promise; checkForSandboxingPrereqs(forceRefresh?: boolean): Promise; - wrapCommand(command: string, requestUnsandboxedExecution?: boolean): string; + wrapCommand(command: string, requestUnsandboxedExecution?: boolean): ITerminalSandboxWrapResult; getSandboxConfigPath(forceRefresh?: boolean): Promise; getTempDir(): URI | undefined; setNeedsForceUpdateConfigFile(): void; @@ -124,6 +132,9 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb private _os: OperatingSystem = OS; private _defaultWritePaths: string[] = ['~/.npm']; private static readonly _sandboxTempDirName = 'tmp'; + private static readonly _urlRegex = /(?:https?|wss?):\/\/[^\s'"`|&;<>]+/gi; + private static readonly _sshRemoteRegex = /(?:^|[\s'"`])(?:[^\s@:'"`]+@)?([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})(?::[^\s'"`|&;<>]+)(?=$|[\s'"`|&;<>])/gi; + private static readonly _hostRegex = /(?:^|[\s'"`(=])([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})(?::\d+)?(?=(?:\/[^\s'"`|&;<>]*)?(?:$|[\s'"`)\]|,;|&<>]))/gi; constructor( @IConfigurationService private readonly _configurationService: IConfigurationService, @@ -189,13 +200,28 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb return this._os; } - public wrapCommand(command: string, requestUnsandboxedExecution?: boolean): string { + public wrapCommand(command: string, requestUnsandboxedExecution?: boolean): ITerminalSandboxWrapResult { if (!this._sandboxConfigPath || !this._tempDir) { throw new Error('Sandbox config path or temp dir not initialized'); } + + const blockedDomainResult = requestUnsandboxedExecution ? { blockedDomains: [], deniedDomains: [] } : this._getBlockedDomains(command); + if (!requestUnsandboxedExecution && blockedDomainResult.blockedDomains.length > 0) { + return { + command: this._wrapUnsandboxedCommand(command), + isSandboxWrapped: false, + blockedDomains: blockedDomainResult.blockedDomains, + deniedDomains: blockedDomainResult.deniedDomains, + requiresUnsandboxConfirmation: true, + }; + } + // If requestUnsandboxedExecution is true, need to ensure env variables set during sandbox still apply. if (requestUnsandboxedExecution) { - return this._tempDir?.path ? `(TMPDIR="${this._tempDir.path}"; export TMPDIR; ${command})` : command; + return { + command: this._wrapUnsandboxedCommand(command), + isSandboxWrapped: false, + }; } if (!this._execPath) { @@ -212,9 +238,15 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb // Quote shell arguments so the wrapped command cannot break out of the outer shell. const wrappedCommand = `PATH="$PATH:${dirname(this._rgPath)}" TMPDIR="${this._tempDir.path}" CLAUDE_TMPDIR="${this._tempDir.path}" "${this._execPath}" "${this._srtPath}" --settings "${this._sandboxConfigPath}" -c ${this._quoteShellArgument(command)}`; if (this._remoteEnvDetails) { - return `${wrappedCommand}`; + return { + command: wrappedCommand, + isSandboxWrapped: true, + }; } - return `ELECTRON_RUN_AS_NODE=1 ${wrappedCommand}`; + return { + command: `ELECTRON_RUN_AS_NODE=1 ${wrappedCommand}`, + isSandboxWrapped: true, + }; } public getTempDir(): URI | undefined { @@ -421,6 +453,148 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb return `'${value.replace(/'/g, `'\\''`)}'`; } + private _wrapUnsandboxedCommand(command: string): string { + return this._tempDir?.path ? `(TMPDIR="${this._tempDir.path}"; export TMPDIR; ${command})` : command; + } + + private _getBlockedDomains(command: string): { blockedDomains: string[]; deniedDomains: string[] } { + const domains = this._extractDomains(command); + if (domains.length === 0) { + return { blockedDomains: [], deniedDomains: [] }; + } + + const { allowedDomains, deniedDomains } = this.getResolvedNetworkDomains(); + const blockedDomains = new Set(); + const explicitlyDeniedDomains = new Set(); + for (const domain of domains) { + if (deniedDomains.some(pattern => this._matchesDomainPattern(domain, pattern))) { + blockedDomains.add(domain); + explicitlyDeniedDomains.add(domain); + continue; + } + if (!allowedDomains.some(pattern => this._matchesDomainPattern(domain, pattern))) { + blockedDomains.add(domain); + } + } + return { + blockedDomains: [...blockedDomains], + deniedDomains: [...explicitlyDeniedDomains], + }; + } + + private _extractDomains(command: string): string[] { + const domains = new Set(); + let match: RegExpExecArray | null; + + TerminalSandboxService._urlRegex.lastIndex = 0; + while ((match = TerminalSandboxService._urlRegex.exec(command)) !== null) { + const domain = this._extractDomainFromUrl(match[0]); + if (domain) { + domains.add(domain); + } + } + + TerminalSandboxService._sshRemoteRegex.lastIndex = 0; + while ((match = TerminalSandboxService._sshRemoteRegex.exec(command)) !== null) { + const domain = this._normalizeDomain(match[1]); + if (domain) { + domains.add(domain); + } + } + + TerminalSandboxService._hostRegex.lastIndex = 0; + while ((match = TerminalSandboxService._hostRegex.exec(command)) !== null) { + const domain = this._normalizeDomain(match[1]); + if (domain) { + domains.add(domain); + } + } + + return [...domains]; + } + + private _extractDomainFromUrl(value: string): string | undefined { + try { + const authority = URI.parse(value).authority; + return this._normalizeDomain(authority); + } catch { + return undefined; + } + } + + private _normalizeDomain(value: string | undefined): string | undefined { + if (!value) { + return undefined; + } + + const normalized = value.trim().toLowerCase().replace(/^[^@]+@/, '').replace(/:\d+$/, '').replace(/\.+$/, ''); + if (!normalized || normalized.includes('/') || normalized === '.' || normalized === '..') { + return undefined; + } + if (normalized !== '*' && !/^\*?\.?[a-z0-9.-]+$/.test(normalized)) { + return undefined; + } + const domainToValidate = normalized.startsWith('*.') ? normalized.slice(2) : normalized; + if (!/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)(?:\.(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?))*$/.test(domainToValidate)) { + return undefined; + } + + // Strip common trailing punctuation that may follow a domain in text, e.g. "example.com,". + const stripped = normalized.replace(/[),;:!?]+$/, ''); + if (!stripped) { + return undefined; + } + + // Allow a bare wildcard pattern. + if (stripped === '*') { + return stripped; + } + + // Support wildcard domain patterns like "*.example.com". + const hasWildcardPrefix = stripped.startsWith('*.'); + const host = hasWildcardPrefix ? stripped.slice(2) : stripped; + if (!host) { + return undefined; + } + + // Validate that the host part only contains valid hostname characters. + if (!/^[a-z0-9.-]+$/.test(host)) { + return undefined; + } + + return hasWildcardPrefix ? `*.${host}` : host; + } + + private _matchesDomainPattern(domain: string, pattern: string): boolean { + const normalizedPattern = this._normalizeDomain(this._extractDomainPattern(pattern)); + if (!normalizedPattern) { + return false; + } + if (normalizedPattern === '*') { + return true; + } + if (normalizedPattern.startsWith('*.')) { + const suffix = normalizedPattern.slice(2); + return domain === suffix || domain.endsWith(`.${suffix}`); + } + return domain === normalizedPattern; + } + + private _extractDomainPattern(pattern: string): string { + const trimmed = pattern.trim(); + if (trimmed === '*') { + return trimmed; + } + if (!trimmed.includes('://')) { + return trimmed; + } + try { + return URI.parse(trimmed).authority; + } catch { + return trimmed; + } + } + private async _isSandboxConfiguredEnabled(): Promise { const os = await this.getOS(); if (os === OperatingSystem.Windows) { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxedCommandLinePresenter.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxedCommandLinePresenter.test.ts index e3e2cca256f..ab879894fcb 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxedCommandLinePresenter.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sandboxedCommandLinePresenter.test.ts @@ -20,7 +20,10 @@ suite('SandboxedCommandLinePresenter', () => { instantiationService.stub(ITerminalSandboxService, { _serviceBrand: undefined, isEnabled: async () => enabled, - wrapCommand: command => command, + wrapCommand: command => ({ + command, + isSandboxWrapped: false, + }), getSandboxConfigPath: async () => '/tmp/sandbox.json', getTempDir: () => undefined, setNeedsForceUpdateConfigFile: () => { }, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index 5236c0e15fc..34b952ba332 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -312,23 +312,176 @@ suite('TerminalSandboxService - network domains', () => { const wrappedCommand = sandboxService.wrapCommand('echo test'); ok( - wrappedCommand.includes('PATH') && wrappedCommand.includes('ripgrep'), + wrappedCommand.command.includes('PATH') && wrappedCommand.command.includes('ripgrep'), 'Wrapped command should include PATH modification with ripgrep' ); + strictEqual(wrappedCommand.isSandboxWrapped, true, 'Command should stay sandbox wrapped when no domain is detected'); }); test('should preserve TMPDIR when unsandboxed execution is requested', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - strictEqual(sandboxService.wrapCommand('echo test', true), `(TMPDIR="${sandboxService.getTempDir()?.path}"; export TMPDIR; echo test)`); + strictEqual(sandboxService.wrapCommand('echo test', true).command, `(TMPDIR="${sandboxService.getTempDir()?.path}"; export TMPDIR; echo test)`); }); test('should preserve TMPDIR for piped unsandboxed commands', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - strictEqual(sandboxService.wrapCommand('echo test | cat', true), `(TMPDIR="${sandboxService.getTempDir()?.path}"; export TMPDIR; echo test | cat)`); + strictEqual(sandboxService.wrapCommand('echo test | cat', true).command, `(TMPDIR="${sandboxService.getTempDir()?.path}"; export TMPDIR; echo test | cat)`); + }); + + test('should switch to unsandboxed execution when a domain is not allowlisted', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + const wrapResult = sandboxService.wrapCommand('curl https://example.com'); + + strictEqual(wrapResult.isSandboxWrapped, false, 'Blocked domains should prevent sandbox wrapping'); + strictEqual(wrapResult.requiresUnsandboxConfirmation, true, 'Blocked domains should require unsandbox confirmation'); + deepStrictEqual(wrapResult.blockedDomains, ['example.com']); + strictEqual(wrapResult.command, `(TMPDIR="${sandboxService.getTempDir()?.path}"; export TMPDIR; curl https://example.com)`); + }); + + test('should allow exact allowlisted domains', async () => { + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxNetworkAllowedDomains, ['example.com']); + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + const wrapResult = sandboxService.wrapCommand('curl https://example.com'); + + strictEqual(wrapResult.isSandboxWrapped, true, 'Exact allowlisted domains should stay sandboxed'); + strictEqual(wrapResult.blockedDomains, undefined, 'Allowed domains should not be reported as blocked'); + }); + + test('should allow wildcard domains', async () => { + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxNetworkAllowedDomains, ['*.github.com']); + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + const wrapResult = sandboxService.wrapCommand('curl "https://api.github.com/repos/microsoft/vscode"'); + + strictEqual(wrapResult.isSandboxWrapped, true, 'Wildcard allowlisted domains should stay sandboxed'); + strictEqual(wrapResult.blockedDomains, undefined, 'Wildcard allowlisted domains should not be reported as blocked'); + }); + + test('should give denied domains precedence over allowlisted domains', async () => { + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxNetworkAllowedDomains, ['*.github.com']); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxNetworkDeniedDomains, ['api.github.com']); + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + const wrapResult = sandboxService.wrapCommand('curl https://api.github.com/repos/microsoft/vscode'); + + strictEqual(wrapResult.isSandboxWrapped, false, 'Denied domains should not stay sandboxed'); + deepStrictEqual(wrapResult.blockedDomains, ['api.github.com']); + deepStrictEqual(wrapResult.deniedDomains, ['api.github.com']); + }); + + test('should match uppercase hostnames when checking allowlisted domains', async () => { + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.AgentSandboxNetworkAllowedDomains, ['*.github.com']); + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + const wrapResult = sandboxService.wrapCommand('curl https://API.GITHUB.COM/repos/microsoft/vscode'); + + strictEqual(wrapResult.isSandboxWrapped, true, 'Uppercase hostnames should still match allowlisted domains'); + strictEqual(wrapResult.blockedDomains, undefined, 'Uppercase allowlisted domains should not be reported as blocked'); + }); + + test('should ignore malformed URL authorities with trailing punctuation', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + const wrapResult = sandboxService.wrapCommand('curl https://example.com]/path'); + + strictEqual(wrapResult.isSandboxWrapped, true, 'Malformed URL authorities should not trigger blocked-domain prompts'); + strictEqual(wrapResult.blockedDomains, undefined, 'Malformed URL authorities should be ignored'); + }); + + test('should not fall back to deprecated settings outside user scope', async () => { + const originalInspect = configurationService.inspect.bind(configurationService); + configurationService.inspect = (key: string) => { + if (key === TerminalChatAgentToolsSettingId.AgentSandboxEnabled) { + return { + value: undefined, + defaultValue: false, + userValue: undefined, + userLocalValue: undefined, + userRemoteValue: undefined, + workspaceValue: undefined, + workspaceFolderValue: undefined, + memoryValue: undefined, + policyValue: undefined, + } as ReturnType>; + } + if (key === TerminalChatAgentToolsSettingId.DeprecatedTerminalSandboxEnabled) { + return { + value: true, + defaultValue: false, + userValue: undefined, + userLocalValue: undefined, + userRemoteValue: undefined, + workspaceValue: true, + workspaceFolderValue: undefined, + memoryValue: undefined, + policyValue: undefined, + } as ReturnType>; + } + return originalInspect(key); + }; + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + + strictEqual(await sandboxService.isEnabled(), false, 'Deprecated settings should not be used when only non-user scopes are set'); + }); + + test('should fall back to deprecated settings in user scope', async () => { + const originalInspect = configurationService.inspect.bind(configurationService); + configurationService.inspect = (key: string) => { + if (key === TerminalChatAgentToolsSettingId.AgentSandboxEnabled) { + return { + value: undefined, + defaultValue: false, + userValue: undefined, + userLocalValue: undefined, + userRemoteValue: undefined, + workspaceValue: undefined, + workspaceFolderValue: undefined, + memoryValue: undefined, + policyValue: undefined, + } as ReturnType>; + } + if (key === TerminalChatAgentToolsSettingId.DeprecatedTerminalSandboxEnabled) { + return { + value: true, + defaultValue: false, + userValue: true, + userLocalValue: true, + userRemoteValue: undefined, + workspaceValue: undefined, + workspaceFolderValue: undefined, + memoryValue: undefined, + policyValue: undefined, + } as ReturnType>; + } + return originalInspect(key); + }; + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + + strictEqual(await sandboxService.isEnabled(), true, 'Deprecated settings should still be respected when only the user scope is set'); + }); + + test('should detect ssh style remotes as domains', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + const wrapResult = sandboxService.wrapCommand('git clone git@github.com:microsoft/vscode.git'); + + strictEqual(wrapResult.isSandboxWrapped, false, 'SSH-style remotes should trigger domain checks'); + deepStrictEqual(wrapResult.blockedDomains, ['github.com']); }); test('should pass wrapped command as a single quoted argument', async () => { @@ -336,7 +489,7 @@ suite('TerminalSandboxService - network domains', () => { await sandboxService.getSandboxConfigPath(); const command = '";echo SANDBOX_ESCAPE_REPRO; # $(uname) `id`'; - const wrappedCommand = sandboxService.wrapCommand(command); + const wrappedCommand = sandboxService.wrapCommand(command).command; ok( wrappedCommand.includes(`-c '";echo SANDBOX_ESCAPE_REPRO; # $(uname) \`id\`'`), @@ -352,11 +505,11 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const command = 'echo $HOME $(curl eth0.me) `id`'; - const wrappedCommand = sandboxService.wrapCommand(command); + const command = 'echo $HOME $(printf literal) `id`'; + const wrappedCommand = sandboxService.wrapCommand(command).command; ok( - wrappedCommand.includes(`-c 'echo $HOME $(curl eth0.me) \`id\`'`), + wrappedCommand.includes(`-c 'echo $HOME $(printf literal) \`id\`'`), 'Wrapped command should keep variable and command substitutions inside the quoted argument' ); ok( @@ -365,19 +518,32 @@ suite('TerminalSandboxService - network domains', () => { ); }); + test('should detect blocked domains inside command substitutions', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + const command = 'echo $HOME $(curl eth0.me) `id`'; + const wrapResult = sandboxService.wrapCommand(command); + + strictEqual(wrapResult.isSandboxWrapped, false, 'Commands with blocked domains inside substitutions should not stay sandboxed'); + strictEqual(wrapResult.requiresUnsandboxConfirmation, true, 'Blocked domains inside substitutions should require confirmation'); + deepStrictEqual(wrapResult.blockedDomains, ['eth0.me']); + strictEqual(wrapResult.command, `(TMPDIR="${sandboxService.getTempDir()?.path}"; export TMPDIR; ${command})`); + }); + test('should escape single-quote breakout payloads in wrapped command argument', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const command = `';curl eth0.me; #'`; - const wrappedCommand = sandboxService.wrapCommand(command); + const command = `';printf breakout; #'`; + const wrappedCommand = sandboxService.wrapCommand(command).command; ok( wrappedCommand.includes(`-c '`), 'Wrapped command should continue to use a single-quoted -c argument' ); ok( - wrappedCommand.includes('curl eth0.me'), + wrappedCommand.includes('printf breakout'), 'Wrapped command should preserve the payload text literally' ); ok( @@ -391,7 +557,7 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrappedCommand = sandboxService.wrapCommand(`echo 'hello'`); + const wrappedCommand = sandboxService.wrapCommand(`echo 'hello'`).command; strictEqual((wrappedCommand.match(/\\''/g) ?? []).length, 2, 'Single quote escapes should be inserted for each embedded single quote'); }); }); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts index 18bfc7a55e6..10cf4d3d03a 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/commandLineSandboxRewriter.test.ts @@ -22,7 +22,12 @@ suite('CommandLineSandboxRewriter', () => { instantiationService.stub(ITerminalSandboxService, { _serviceBrand: undefined, isEnabled: async () => false, - wrapCommand: (command, _requestUnsandboxedExecution) => command, + wrapCommand: (command, _requestUnsandboxedExecution) => { + return { + command, + isSandboxWrapped: false, + }; + }, getSandboxConfigPath: async () => '/tmp/sandbox.json', checkForSandboxingPrereqs: async () => ({ enabled: false, sandboxConfigPath: undefined, failedCheck: TerminalSandboxPrerequisiteCheck.Config }), getTempDir: () => undefined, @@ -49,7 +54,10 @@ suite('CommandLineSandboxRewriter', () => { test('returns undefined when sandbox config is unavailable', async () => { stubSandboxService({ - wrapCommand: command => `wrapped:${command}`, + wrapCommand: command => ({ + command: `wrapped:${command}`, + isSandboxWrapped: true, + }), checkForSandboxingPrereqs: async () => ({ enabled: false, sandboxConfigPath: undefined, failedCheck: TerminalSandboxPrerequisiteCheck.Config }), }); @@ -78,7 +86,10 @@ suite('CommandLineSandboxRewriter', () => { stubSandboxService({ wrapCommand: (command, _requestUnsandboxedExecution) => { calls.push('wrapCommand'); - return `wrapped:${command}`; + return { + command: `wrapped:${command}`, + isSandboxWrapped: true, + }; }, checkForSandboxingPrereqs: async () => { calls.push('checkForSandboxingPrereqs'); @@ -98,7 +109,10 @@ suite('CommandLineSandboxRewriter', () => { stubSandboxService({ wrapCommand: (command, requestUnsandboxedExecution) => { calls.push(`wrap:${command}:${String(requestUnsandboxedExecution)}`); - return `wrapped:${command}`; + return { + command: `wrapped:${command}`, + isSandboxWrapped: !requestUnsandboxedExecution, + }; }, checkForSandboxingPrereqs: async () => { calls.push('prereqs'); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index c0430d89c80..bfb15912407 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -153,7 +153,10 @@ suite('RunInTerminalTool', () => { terminalSandboxService = { _serviceBrand: undefined, isEnabled: async () => sandboxEnabled, - wrapCommand: (command: string, requestUnsandboxedExecution?: boolean) => requestUnsandboxedExecution ? `unsandboxed:${command}` : `sandbox:${command}`, + wrapCommand: (command: string, requestUnsandboxedExecution?: boolean) => ({ + command: requestUnsandboxedExecution ? `unsandboxed:${command}` : `sandbox:${command}`, + isSandboxWrapped: !requestUnsandboxedExecution, + }), getSandboxConfigPath: async () => sandboxEnabled ? '/tmp/sandbox.json' : undefined, checkForSandboxingPrereqs: async () => sandboxPrereqResult, getTempDir: () => undefined, @@ -363,7 +366,10 @@ suite('RunInTerminalTool', () => { sandboxConfigPath: '/tmp/vscode-sandbox-settings.json', failedCheck: undefined, }; - terminalSandboxService.wrapCommand = (command: string) => `sandbox-runtime ${command}`; + terminalSandboxService.wrapCommand = (command: string) => ({ + command: `sandbox-runtime ${command}`, + isSandboxWrapped: true, + }); const preparedInvocation = await executeToolTest({ command: 'echo hello' }); @@ -613,6 +619,33 @@ suite('RunInTerminalTool', () => { }); suite('sandbox bypass requests', () => { + test('should mention denied domains when sandbox denies network access explicitly', async () => { + sandboxEnabled = true; + sandboxPrereqResult = { + enabled: true, + sandboxConfigPath: '/tmp/sandbox.json', + failedCheck: undefined, + }; + runInTerminalTool.setBackendOs(OperatingSystem.Linux); + terminalSandboxService.wrapCommand = (command: string) => ({ + command: `unsandboxed:${command}`, + isSandboxWrapped: false, + requiresUnsandboxConfirmation: true, + blockedDomains: ['evil.com'], + deniedDomains: ['evil.com'], + }); + + const result = await executeToolTest({ command: 'curl https://evil.com' }); + + assertConfirmationRequired(result, 'Run `bash` command outside the [sandbox](https://aka.ms/vscode-sandboxing) to access `evil.com`?'); + const confirmationMessage = result?.confirmationMessages?.message; + ok(confirmationMessage && typeof confirmationMessage !== 'string'); + if (!confirmationMessage || typeof confirmationMessage === 'string') { + throw new Error('Expected markdown confirmation message'); + } + ok(confirmationMessage.value.includes('Reason for leaving the sandbox: This command accesses evil.com, which is blocked by chat.agent.sandboxNetwork.deniedDomains.')); + }); + test('should force confirmation for explicit unsandboxed execution requests', async () => { sandboxEnabled = true; sandboxPrereqResult = { @@ -2025,7 +2058,10 @@ suite('ChatAgentToolsContribution - tool registration refresh', () => { const terminalSandboxService: ITerminalSandboxService = { _serviceBrand: undefined, isEnabled: async () => sandboxEnabled, - wrapCommand: (command: string) => `sandbox:${command}`, + wrapCommand: (command: string) => ({ + command: `sandbox:${command}`, + isSandboxWrapped: true, + }), getSandboxConfigPath: async () => sandboxEnabled ? '/tmp/sandbox.json' : undefined, checkForSandboxingPrereqs: async () => ({ enabled: sandboxEnabled, sandboxConfigPath: sandboxEnabled ? '/tmp/sandbox.json' : undefined, failedCheck: undefined }), getTempDir: () => undefined,