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,