mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-17 23:35:54 +01:00
[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:
@@ -315,30 +315,22 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
|
|||||||
assert.strictEqual(output.trim(), marker);
|
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);
|
this.timeout(60000);
|
||||||
|
|
||||||
const output = await invokeRunInTerminal('curl -s --max-time 5 https://example.com');
|
const configuration = vscode.workspace.getConfiguration();
|
||||||
|
await configuration.update('chat.agent.sandboxNetwork.allowedDomains', ['example.com'], vscode.ConfigurationTarget.Global);
|
||||||
// Without shell integration, exit code is unavailable and
|
try {
|
||||||
// curl produces no sandbox-specific error strings, so the
|
const output = await invokeRunInTerminal('curl -s --max-time 5 https://example.com');
|
||||||
// sandbox analyzer may not trigger.
|
const trimmed = output.trim();
|
||||||
const acceptable = [
|
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:',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'',
|
|
||||||
'Command produced no output',
|
'Command produced no output',
|
||||||
'Command exited with code 56',
|
'<title>Example Domain</title>',
|
||||||
].join('\n'),
|
];
|
||||||
...(!hasShellIntegration ? ['Command produced no output'] : []),
|
assert.ok(acceptable.some(value => trimmed.includes(value) || trimmed === value), `Unexpected output: ${JSON.stringify(trimmed)}`);
|
||||||
];
|
} finally {
|
||||||
assert.ok(acceptable.includes(output.trim()), `Unexpected output: ${JSON.stringify(output.trim())}`);
|
await configuration.update('chat.agent.sandboxNetwork.allowedDomains', undefined, vscode.ConfigurationTarget.Global);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('requestUnsandboxedExecution preserves sandbox $TMPDIR', async function () {
|
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.
|
// Step 1: Write a sentinel file into the sandbox-provided $TMPDIR.
|
||||||
const writeOutput = await invokeRunInTerminal(`echo ${marker} > "$TMPDIR/${sentinelName}" && echo ${marker}`);
|
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
|
// Step 2: Retry with requestUnsandboxedExecution=true while sandbox
|
||||||
// stays enabled. The tool should preserve $TMPDIR from the sandbox so
|
// stays enabled. The tool should preserve $TMPDIR from the sandbox so
|
||||||
@@ -359,7 +355,10 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
|
|||||||
requestUnsandboxedExecution: true,
|
requestUnsandboxedExecution: true,
|
||||||
requestUnsandboxedExecutionReason: 'Need to verify $TMPDIR persists on unsandboxed retry',
|
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 () {
|
test('cannot write to /tmp', async function () {
|
||||||
@@ -422,6 +421,18 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
|
|||||||
|
|
||||||
assert.strictEqual(output.trim(), marker);
|
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)}`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
//for scenarios where we want to show a different command in the chat UI than what is actually run in the terminal
|
||||||
forDisplay?: string;
|
forDisplay?: string;
|
||||||
isSandboxWrapped?: boolean;
|
isSandboxWrapped?: boolean;
|
||||||
|
requiresUnsandboxConfirmation?: boolean;
|
||||||
|
blockedDomains?: string[];
|
||||||
|
deniedDomains?: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ export class CommandLineSandboxRewriter extends Disposable implements ICommandLi
|
|||||||
|
|
||||||
const wrappedCommand = this._sandboxService.wrapCommand(options.commandLine, options.requestUnsandboxedExecution);
|
const wrappedCommand = this._sandboxService.wrapCommand(options.commandLine, options.requestUnsandboxedExecution);
|
||||||
return {
|
return {
|
||||||
rewritten: wrappedCommand,
|
rewritten: wrappedCommand.command,
|
||||||
reasoning: 'Wrapped command for sandbox execution',
|
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)
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -562,7 +562,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
|
|||||||
]);
|
]);
|
||||||
const language = os === OperatingSystem.Windows ? 'pwsh' : 'sh';
|
const language = os === OperatingSystem.Windows ? 'pwsh' : 'sh';
|
||||||
const isTerminalSandboxEnabled = sandboxPrereqs.enabled;
|
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
|
const missingDependencies = sandboxPrereqs.failedCheck === TerminalSandboxPrerequisiteCheck.Dependencies && sandboxPrereqs.missingDependencies?.length
|
||||||
? sandboxPrereqs.missingDependencies
|
? sandboxPrereqs.missingDependencies
|
||||||
@@ -588,6 +591,15 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
|
|||||||
forDisplayCommand = rewriteResult.forDisplay ?? forDisplayCommand;
|
forDisplayCommand = rewriteResult.forDisplay ?? forDisplayCommand;
|
||||||
if (rewriteResult.isSandboxWrapped) {
|
if (rewriteResult.isSandboxWrapped) {
|
||||||
isSandboxWrapped = true;
|
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}`);
|
this._logService.info(`RunInTerminalTool: Command rewritten by ${rewriter.constructor.name}: ${rewriteResult.reasoning}`);
|
||||||
}
|
}
|
||||||
@@ -607,7 +619,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
|
|||||||
language,
|
language,
|
||||||
isBackground: args.isBackground,
|
isBackground: args.isBackground,
|
||||||
requestUnsandboxedExecution: requiresUnsandboxConfirmation,
|
requestUnsandboxedExecution: requiresUnsandboxConfirmation,
|
||||||
requestUnsandboxedExecutionReason: args.requestUnsandboxedExecutionReason,
|
requestUnsandboxedExecutionReason,
|
||||||
missingSandboxDependencies: missingDependencies,
|
missingSandboxDependencies: missingDependencies,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -803,9 +815,13 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (requiresUnsandboxConfirmation) {
|
if (requiresUnsandboxConfirmation) {
|
||||||
confirmationTitle = args.isBackground
|
confirmationTitle = blockedDomains?.length
|
||||||
? localize('runInTerminal.unsandboxed.background', "Run `{0}` command outside the [sandbox]({1}) in background?", shellType, TERMINAL_SANDBOX_DOCUMENTATION_URL)
|
? (args.isBackground
|
||||||
: localize('runInTerminal.unsandboxed', "Run `{0}` command outside the [sandbox]({1})?", shellType, TERMINAL_SANDBOX_DOCUMENTATION_URL);
|
? 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
|
// 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}",
|
"Explanation: {0}\n\nGoal: {1}\n\nReason for leaving the sandbox: {2}",
|
||||||
args.explanation,
|
args.explanation,
|
||||||
args.goal,
|
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));
|
: new MarkdownString(localize('runInTerminal.confirmationMessage', "Explanation: {0}\n\nGoal: {1}", args.explanation, args.goal));
|
||||||
const confirmationMessages = shouldShowConfirmation ? {
|
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)
|
* Returns true if the chat session's permission level (Autopilot/Bypass Approvals)
|
||||||
* auto-approves all tool calls, unless enterprise policy restricts it.
|
* auto-approves all tool calls, unless enterprise policy restricts it.
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ export interface ITerminalSandboxPrerequisiteCheckResult {
|
|||||||
missingDependencies?: string[];
|
missingDependencies?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ITerminalSandboxWrapResult {
|
||||||
|
command: string;
|
||||||
|
isSandboxWrapped: boolean;
|
||||||
|
blockedDomains?: string[];
|
||||||
|
deniedDomains?: string[];
|
||||||
|
requiresUnsandboxConfirmation?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstraction over terminal operations needed by the install flow.
|
* Abstraction over terminal operations needed by the install flow.
|
||||||
* Provided by the browser-layer caller so the common-layer service
|
* Provided by the browser-layer caller so the common-layer service
|
||||||
@@ -98,7 +106,7 @@ export interface ITerminalSandboxService {
|
|||||||
isEnabled(): Promise<boolean>;
|
isEnabled(): Promise<boolean>;
|
||||||
getOS(): Promise<OperatingSystem>;
|
getOS(): Promise<OperatingSystem>;
|
||||||
checkForSandboxingPrereqs(forceRefresh?: boolean): Promise<ITerminalSandboxPrerequisiteCheckResult>;
|
checkForSandboxingPrereqs(forceRefresh?: boolean): Promise<ITerminalSandboxPrerequisiteCheckResult>;
|
||||||
wrapCommand(command: string, requestUnsandboxedExecution?: boolean): string;
|
wrapCommand(command: string, requestUnsandboxedExecution?: boolean): ITerminalSandboxWrapResult;
|
||||||
getSandboxConfigPath(forceRefresh?: boolean): Promise<string | undefined>;
|
getSandboxConfigPath(forceRefresh?: boolean): Promise<string | undefined>;
|
||||||
getTempDir(): URI | undefined;
|
getTempDir(): URI | undefined;
|
||||||
setNeedsForceUpdateConfigFile(): void;
|
setNeedsForceUpdateConfigFile(): void;
|
||||||
@@ -124,6 +132,9 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb
|
|||||||
private _os: OperatingSystem = OS;
|
private _os: OperatingSystem = OS;
|
||||||
private _defaultWritePaths: string[] = ['~/.npm'];
|
private _defaultWritePaths: string[] = ['~/.npm'];
|
||||||
private static readonly _sandboxTempDirName = 'tmp';
|
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(
|
constructor(
|
||||||
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
@IConfigurationService private readonly _configurationService: IConfigurationService,
|
||||||
@@ -189,13 +200,28 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb
|
|||||||
return this._os;
|
return this._os;
|
||||||
}
|
}
|
||||||
|
|
||||||
public wrapCommand(command: string, requestUnsandboxedExecution?: boolean): string {
|
public wrapCommand(command: string, requestUnsandboxedExecution?: boolean): ITerminalSandboxWrapResult {
|
||||||
if (!this._sandboxConfigPath || !this._tempDir) {
|
if (!this._sandboxConfigPath || !this._tempDir) {
|
||||||
throw new Error('Sandbox config path or temp dir not initialized');
|
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 is true, need to ensure env variables set during sandbox still apply.
|
||||||
if (requestUnsandboxedExecution) {
|
if (requestUnsandboxedExecution) {
|
||||||
return this._tempDir?.path ? `(TMPDIR="${this._tempDir.path}"; export TMPDIR; ${command})` : command;
|
return {
|
||||||
|
command: this._wrapUnsandboxedCommand(command),
|
||||||
|
isSandboxWrapped: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this._execPath) {
|
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.
|
// 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)}`;
|
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) {
|
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 {
|
public getTempDir(): URI | undefined {
|
||||||
@@ -421,6 +453,148 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb
|
|||||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
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> {
|
private async _isSandboxConfiguredEnabled(): Promise<boolean> {
|
||||||
const os = await this.getOS();
|
const os = await this.getOS();
|
||||||
if (os === OperatingSystem.Windows) {
|
if (os === OperatingSystem.Windows) {
|
||||||
|
|||||||
@@ -20,7 +20,10 @@ suite('SandboxedCommandLinePresenter', () => {
|
|||||||
instantiationService.stub(ITerminalSandboxService, {
|
instantiationService.stub(ITerminalSandboxService, {
|
||||||
_serviceBrand: undefined,
|
_serviceBrand: undefined,
|
||||||
isEnabled: async () => enabled,
|
isEnabled: async () => enabled,
|
||||||
wrapCommand: command => command,
|
wrapCommand: command => ({
|
||||||
|
command,
|
||||||
|
isSandboxWrapped: false,
|
||||||
|
}),
|
||||||
getSandboxConfigPath: async () => '/tmp/sandbox.json',
|
getSandboxConfigPath: async () => '/tmp/sandbox.json',
|
||||||
getTempDir: () => undefined,
|
getTempDir: () => undefined,
|
||||||
setNeedsForceUpdateConfigFile: () => { },
|
setNeedsForceUpdateConfigFile: () => { },
|
||||||
|
|||||||
@@ -312,23 +312,176 @@ suite('TerminalSandboxService - network domains', () => {
|
|||||||
const wrappedCommand = sandboxService.wrapCommand('echo test');
|
const wrappedCommand = sandboxService.wrapCommand('echo test');
|
||||||
|
|
||||||
ok(
|
ok(
|
||||||
wrappedCommand.includes('PATH') && wrappedCommand.includes('ripgrep'),
|
wrappedCommand.command.includes('PATH') && wrappedCommand.command.includes('ripgrep'),
|
||||||
'Wrapped command should include PATH modification with 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 () => {
|
test('should preserve TMPDIR when unsandboxed execution is requested', async () => {
|
||||||
const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService));
|
const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService));
|
||||||
await sandboxService.getSandboxConfigPath();
|
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 () => {
|
test('should preserve TMPDIR for piped unsandboxed commands', async () => {
|
||||||
const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService));
|
const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService));
|
||||||
await sandboxService.getSandboxConfigPath();
|
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 () => {
|
test('should pass wrapped command as a single quoted argument', async () => {
|
||||||
@@ -336,7 +489,7 @@ suite('TerminalSandboxService - network domains', () => {
|
|||||||
await sandboxService.getSandboxConfigPath();
|
await sandboxService.getSandboxConfigPath();
|
||||||
|
|
||||||
const command = '";echo SANDBOX_ESCAPE_REPRO; # $(uname) `id`';
|
const command = '";echo SANDBOX_ESCAPE_REPRO; # $(uname) `id`';
|
||||||
const wrappedCommand = sandboxService.wrapCommand(command);
|
const wrappedCommand = sandboxService.wrapCommand(command).command;
|
||||||
|
|
||||||
ok(
|
ok(
|
||||||
wrappedCommand.includes(`-c '";echo SANDBOX_ESCAPE_REPRO; # $(uname) \`id\`'`),
|
wrappedCommand.includes(`-c '";echo SANDBOX_ESCAPE_REPRO; # $(uname) \`id\`'`),
|
||||||
@@ -352,11 +505,11 @@ suite('TerminalSandboxService - network domains', () => {
|
|||||||
const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService));
|
const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService));
|
||||||
await sandboxService.getSandboxConfigPath();
|
await sandboxService.getSandboxConfigPath();
|
||||||
|
|
||||||
const command = 'echo $HOME $(curl eth0.me) `id`';
|
const command = 'echo $HOME $(printf literal) `id`';
|
||||||
const wrappedCommand = sandboxService.wrapCommand(command);
|
const wrappedCommand = sandboxService.wrapCommand(command).command;
|
||||||
|
|
||||||
ok(
|
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'
|
'Wrapped command should keep variable and command substitutions inside the quoted argument'
|
||||||
);
|
);
|
||||||
ok(
|
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 () => {
|
test('should escape single-quote breakout payloads in wrapped command argument', async () => {
|
||||||
const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService));
|
const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService));
|
||||||
await sandboxService.getSandboxConfigPath();
|
await sandboxService.getSandboxConfigPath();
|
||||||
|
|
||||||
const command = `';curl eth0.me; #'`;
|
const command = `';printf breakout; #'`;
|
||||||
const wrappedCommand = sandboxService.wrapCommand(command);
|
const wrappedCommand = sandboxService.wrapCommand(command).command;
|
||||||
|
|
||||||
ok(
|
ok(
|
||||||
wrappedCommand.includes(`-c '`),
|
wrappedCommand.includes(`-c '`),
|
||||||
'Wrapped command should continue to use a single-quoted -c argument'
|
'Wrapped command should continue to use a single-quoted -c argument'
|
||||||
);
|
);
|
||||||
ok(
|
ok(
|
||||||
wrappedCommand.includes('curl eth0.me'),
|
wrappedCommand.includes('printf breakout'),
|
||||||
'Wrapped command should preserve the payload text literally'
|
'Wrapped command should preserve the payload text literally'
|
||||||
);
|
);
|
||||||
ok(
|
ok(
|
||||||
@@ -391,7 +557,7 @@ suite('TerminalSandboxService - network domains', () => {
|
|||||||
const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService));
|
const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService));
|
||||||
await sandboxService.getSandboxConfigPath();
|
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');
|
strictEqual((wrappedCommand.match(/\\''/g) ?? []).length, 2, 'Single quote escapes should be inserted for each embedded single quote');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,7 +22,12 @@ suite('CommandLineSandboxRewriter', () => {
|
|||||||
instantiationService.stub(ITerminalSandboxService, {
|
instantiationService.stub(ITerminalSandboxService, {
|
||||||
_serviceBrand: undefined,
|
_serviceBrand: undefined,
|
||||||
isEnabled: async () => false,
|
isEnabled: async () => false,
|
||||||
wrapCommand: (command, _requestUnsandboxedExecution) => command,
|
wrapCommand: (command, _requestUnsandboxedExecution) => {
|
||||||
|
return {
|
||||||
|
command,
|
||||||
|
isSandboxWrapped: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
getSandboxConfigPath: async () => '/tmp/sandbox.json',
|
getSandboxConfigPath: async () => '/tmp/sandbox.json',
|
||||||
checkForSandboxingPrereqs: async () => ({ enabled: false, sandboxConfigPath: undefined, failedCheck: TerminalSandboxPrerequisiteCheck.Config }),
|
checkForSandboxingPrereqs: async () => ({ enabled: false, sandboxConfigPath: undefined, failedCheck: TerminalSandboxPrerequisiteCheck.Config }),
|
||||||
getTempDir: () => undefined,
|
getTempDir: () => undefined,
|
||||||
@@ -49,7 +54,10 @@ suite('CommandLineSandboxRewriter', () => {
|
|||||||
|
|
||||||
test('returns undefined when sandbox config is unavailable', async () => {
|
test('returns undefined when sandbox config is unavailable', async () => {
|
||||||
stubSandboxService({
|
stubSandboxService({
|
||||||
wrapCommand: command => `wrapped:${command}`,
|
wrapCommand: command => ({
|
||||||
|
command: `wrapped:${command}`,
|
||||||
|
isSandboxWrapped: true,
|
||||||
|
}),
|
||||||
checkForSandboxingPrereqs: async () => ({ enabled: false, sandboxConfigPath: undefined, failedCheck: TerminalSandboxPrerequisiteCheck.Config }),
|
checkForSandboxingPrereqs: async () => ({ enabled: false, sandboxConfigPath: undefined, failedCheck: TerminalSandboxPrerequisiteCheck.Config }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,7 +86,10 @@ suite('CommandLineSandboxRewriter', () => {
|
|||||||
stubSandboxService({
|
stubSandboxService({
|
||||||
wrapCommand: (command, _requestUnsandboxedExecution) => {
|
wrapCommand: (command, _requestUnsandboxedExecution) => {
|
||||||
calls.push('wrapCommand');
|
calls.push('wrapCommand');
|
||||||
return `wrapped:${command}`;
|
return {
|
||||||
|
command: `wrapped:${command}`,
|
||||||
|
isSandboxWrapped: true,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
checkForSandboxingPrereqs: async () => {
|
checkForSandboxingPrereqs: async () => {
|
||||||
calls.push('checkForSandboxingPrereqs');
|
calls.push('checkForSandboxingPrereqs');
|
||||||
@@ -98,7 +109,10 @@ suite('CommandLineSandboxRewriter', () => {
|
|||||||
stubSandboxService({
|
stubSandboxService({
|
||||||
wrapCommand: (command, requestUnsandboxedExecution) => {
|
wrapCommand: (command, requestUnsandboxedExecution) => {
|
||||||
calls.push(`wrap:${command}:${String(requestUnsandboxedExecution)}`);
|
calls.push(`wrap:${command}:${String(requestUnsandboxedExecution)}`);
|
||||||
return `wrapped:${command}`;
|
return {
|
||||||
|
command: `wrapped:${command}`,
|
||||||
|
isSandboxWrapped: !requestUnsandboxedExecution,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
checkForSandboxingPrereqs: async () => {
|
checkForSandboxingPrereqs: async () => {
|
||||||
calls.push('prereqs');
|
calls.push('prereqs');
|
||||||
|
|||||||
@@ -153,7 +153,10 @@ suite('RunInTerminalTool', () => {
|
|||||||
terminalSandboxService = {
|
terminalSandboxService = {
|
||||||
_serviceBrand: undefined,
|
_serviceBrand: undefined,
|
||||||
isEnabled: async () => sandboxEnabled,
|
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,
|
getSandboxConfigPath: async () => sandboxEnabled ? '/tmp/sandbox.json' : undefined,
|
||||||
checkForSandboxingPrereqs: async () => sandboxPrereqResult,
|
checkForSandboxingPrereqs: async () => sandboxPrereqResult,
|
||||||
getTempDir: () => undefined,
|
getTempDir: () => undefined,
|
||||||
@@ -363,7 +366,10 @@ suite('RunInTerminalTool', () => {
|
|||||||
sandboxConfigPath: '/tmp/vscode-sandbox-settings.json',
|
sandboxConfigPath: '/tmp/vscode-sandbox-settings.json',
|
||||||
failedCheck: undefined,
|
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' });
|
const preparedInvocation = await executeToolTest({ command: 'echo hello' });
|
||||||
|
|
||||||
@@ -613,6 +619,33 @@ suite('RunInTerminalTool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
suite('sandbox bypass requests', () => {
|
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 () => {
|
test('should force confirmation for explicit unsandboxed execution requests', async () => {
|
||||||
sandboxEnabled = true;
|
sandboxEnabled = true;
|
||||||
sandboxPrereqResult = {
|
sandboxPrereqResult = {
|
||||||
@@ -2025,7 +2058,10 @@ suite('ChatAgentToolsContribution - tool registration refresh', () => {
|
|||||||
const terminalSandboxService: ITerminalSandboxService = {
|
const terminalSandboxService: ITerminalSandboxService = {
|
||||||
_serviceBrand: undefined,
|
_serviceBrand: undefined,
|
||||||
isEnabled: async () => sandboxEnabled,
|
isEnabled: async () => sandboxEnabled,
|
||||||
wrapCommand: (command: string) => `sandbox:${command}`,
|
wrapCommand: (command: string) => ({
|
||||||
|
command: `sandbox:${command}`,
|
||||||
|
isSandboxWrapped: true,
|
||||||
|
}),
|
||||||
getSandboxConfigPath: async () => sandboxEnabled ? '/tmp/sandbox.json' : undefined,
|
getSandboxConfigPath: async () => sandboxEnabled ? '/tmp/sandbox.json' : undefined,
|
||||||
checkForSandboxingPrereqs: async () => ({ enabled: sandboxEnabled, sandboxConfigPath: sandboxEnabled ? '/tmp/sandbox.json' : undefined, failedCheck: undefined }),
|
checkForSandboxingPrereqs: async () => ({ enabled: sandboxEnabled, sandboxConfigPath: sandboxEnabled ? '/tmp/sandbox.json' : undefined, failedCheck: undefined }),
|
||||||
getTempDir: () => undefined,
|
getTempDir: () => undefined,
|
||||||
|
|||||||
Reference in New Issue
Block a user