[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 b956dfa719.

* removing local files

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
dileepyavan
2026-03-30 06:33:04 -07:00
committed by GitHub
parent 03e592c629
commit a2d7b9e13b
9 changed files with 508 additions and 56 deletions

View File

@@ -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())}`);
'<title>Example Domain</title>',
];
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)}`);
});
});
}
});

View File

@@ -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[];
}

View File

@@ -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,
};
}
}

View File

@@ -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.

View File

@@ -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<boolean>;
getOS(): Promise<OperatingSystem>;
checkForSandboxingPrereqs(forceRefresh?: boolean): Promise<ITerminalSandboxPrerequisiteCheckResult>;
wrapCommand(command: string, requestUnsandboxedExecution?: boolean): string;
wrapCommand(command: string, requestUnsandboxedExecution?: boolean): ITerminalSandboxWrapResult;
getSandboxConfigPath(forceRefresh?: boolean): Promise<string | undefined>;
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<string>();
const explicitlyDeniedDomains = new Set<string>();
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<string>();
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<boolean> {
const os = await this.getOS();
if (os === OperatingSystem.Windows) {

View File

@@ -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: () => { },

View File

@@ -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 = <T>(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<typeof originalInspect<T>>;
}
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<typeof originalInspect<T>>;
}
return originalInspect<T>(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 = <T>(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<typeof originalInspect<T>>;
}
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<typeof originalInspect<T>>;
}
return originalInspect<T>(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');
});
});

View File

@@ -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');

View File

@@ -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,