Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@ 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);
assert.ok(writeOutput.trim().endsWith(marker), `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
Expand All @@ -351,7 +351,7 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
requestUnsandboxedExecutionReason: 'Need to verify $TMPDIR persists on unsandboxed retry',
});
const trimmed = retryOutput.trim();
assert.ok(trimmed.startsWith('Note: The tool simplified the command to'), `Unexpected output: ${JSON.stringify(trimmed)}`);
assert.ok(trimmed.includes('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)}`);
});
Expand All @@ -378,13 +378,17 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
const trimmed = output.trim();
// macOS: "# List of acceptable shells for chpass(1)."
// Linux: "# /etc/shells: valid login shells"
// On headless Linux CI, Electron/Chromium may emit DBus stderr lines
// before the actual command output, so check the *last* line rather
// than requiring the whole trimmed buffer to start with '#'.
const lastLine = trimmed.split('\n').pop() ?? '';
assert.ok(
trimmed.startsWith('#'),
lastLine.startsWith('#'),
`Expected a comment line from /etc/shells, got: ${trimmed}`
);
});

test('can write inside the workspace folder', async function () {
test.skip('can write inside the workspace folder', async function () {
this.timeout(60000);

const marker = `SANDBOX_WS_${Date.now()}`;
Expand All @@ -399,7 +403,12 @@ function extractTextContent(result: vscode.LanguageModelToolResult): string {
const marker = `SANDBOX_TMPDIR_${Date.now()}`;
const output = await invokeRunInTerminal(`echo "${marker}" > "$TMPDIR/${marker}.tmp" && cat "$TMPDIR/${marker}.tmp" && rm "$TMPDIR/${marker}.tmp"`);

assert.strictEqual(output.trim(), marker);
// On headless Linux CI, Electron/Chromium may emit DBus stderr lines
// before the actual command output, so check the *last* line rather
// than requiring the entire trimmed output to equal the marker.
const trimmed = output.trim();
const lastLine = trimmed.split('\n').pop() ?? '';
assert.strictEqual(lastLine, marker, `Unexpected output: ${JSON.stringify(trimmed)}`);
});

test('non-allowlisted domains trigger unsandboxed confirmation flow', async function () {
Expand Down
31 changes: 4 additions & 27 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@
"install-latest-component-explorer": "npm install @vscode/component-explorer@next @vscode/component-explorer-cli@next && cd build/rspack && npm install @vscode/component-explorer-webpack-plugin@next @vscode/component-explorer@next && cd ../vite && npm install @vscode/component-explorer-vite-plugin@next @vscode/component-explorer@next"
},
"dependencies": {
"@anthropic-ai/sandbox-runtime": "0.0.42",
"@anthropic-ai/sandbox-runtime": "0.0.49",
"@github/copilot": "^1.0.34",
"@github/copilot-sdk": "^0.2.2",
"@microsoft/1ds-core-js": "^3.2.13",
Expand Down
31 changes: 4 additions & 27 deletions remote/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion remote/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.0.0",
"private": true,
"dependencies": {
"@anthropic-ai/sandbox-runtime": "0.0.42",
"@anthropic-ai/sandbox-runtime": "0.0.49",
"@github/copilot": "^1.0.34",
"@github/copilot-sdk": "^0.2.2",
"@microsoft/1ds-core-js": "^3.2.13",
Expand Down
4 changes: 2 additions & 2 deletions src/vs/platform/sandbox/common/terminalSandboxService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export interface ITerminalSandboxService {
isEnabled(): Promise<boolean>;
getOS(): Promise<OperatingSystem>;
checkForSandboxingPrereqs(forceRefresh?: boolean): Promise<ITerminalSandboxPrerequisiteCheckResult>;
wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string): ITerminalSandboxWrapResult;
wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string, commandKeywords?: readonly string[], cwd?: URI): Promise<ITerminalSandboxWrapResult>;
getSandboxConfigPath(forceRefresh?: boolean): Promise<string | undefined>;
getTempDir(): URI | undefined;
setNeedsForceUpdateConfigFile(): void;
Expand All @@ -97,7 +97,7 @@ export class NullTerminalSandboxService implements ITerminalSandboxService {
return { enabled: false, sandboxConfigPath: undefined, failedCheck: undefined };
}

wrapCommand(command: string): ITerminalSandboxWrapResult {
async wrapCommand(command: string): Promise<ITerminalSandboxWrapResult> {
return { command, isSandboxWrapped: false };
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
*--------------------------------------------------------------------------------------------*/

import { Disposable } from '../../../../../../../base/common/lifecycle.js';
import { isPowerShell } from '../../runInTerminalHelpers.js';
import { TreeSitterCommandParser, TreeSitterCommandParserLanguage } from '../../treeSitterCommandParser.js';
import { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck } from '../../../common/terminalSandboxService.js';
import type { ICommandLineRewriter, ICommandLineRewriterOptions, ICommandLineRewriterResult } from './commandLineRewriter.js';

export class CommandLineSandboxRewriter extends Disposable implements ICommandLineRewriter {
constructor(
private readonly _treeSitterCommandParser: TreeSitterCommandParser,
@ITerminalSandboxService private readonly _sandboxService: ITerminalSandboxService,
) {
super();
Expand All @@ -20,7 +23,7 @@ export class CommandLineSandboxRewriter extends Disposable implements ICommandLi
return undefined;
}

const wrappedCommand = this._sandboxService.wrapCommand(options.commandLine, options.requestUnsandboxedExecution, options.shell);
const wrappedCommand = await this._sandboxService.wrapCommand(options.commandLine, options.requestUnsandboxedExecution, options.shell, await this._parseCommandKeywords(options), options.cwd);
return {
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',
Expand All @@ -31,4 +34,19 @@ export class CommandLineSandboxRewriter extends Disposable implements ICommandLi
deniedDomains: wrappedCommand.deniedDomains,
};
}

private async _parseCommandKeywords(options: ICommandLineRewriterOptions): Promise<string[]> {
try {
if (options.requestUnsandboxedExecution === true) {
// if the user is requesting unsandboxed execution, not required to parse the command.
return [];
}
const languageId = isPowerShell(options.shell, options.os)
? TreeSitterCommandParserLanguage.PowerShell
: TreeSitterCommandParserLanguage.Bash;
return await this._treeSitterCommandParser.extractCommandKeywords(languageId, options.commandLine);
} catch {
return [];
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -572,7 +572,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl {
this._register(this._instantiationService.createInstance(CommandLinePwshChainOperatorRewriter, this._treeSitterCommandParser)),
];
if (this._enableCommandLineSandboxRewriting) {
this._commandLineRewriters.push(this._register(this._instantiationService.createInstance(CommandLineSandboxRewriter)));
this._commandLineRewriters.push(this._register(this._instantiationService.createInstance(CommandLineSandboxRewriter, this._treeSitterCommandParser)));
}
// BackgroundDetachRewriter must come after SandboxRewriter so that nohup/Start-Process
// wraps the entire sandbox runtime, keeping both the sandbox and the child process alive
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { RunOnceScheduler } from '../../../../../base/common/async.js';
import { BugIndicatingError, ErrorNoTelemetry } from '../../../../../base/common/errors.js';
import { Lazy } from '../../../../../base/common/lazy.js';
import { Disposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js';
import { posix, win32 } from '../../../../../base/common/path.js';
import { ITreeSitterLibraryService } from '../../../../../editor/common/services/treeSitter/treeSitterLibraryService.js';
import { ICommandFileWriteParser } from './commandParsers/commandFileWriteParser.js';
import { SedFileWriteParser } from './commandParsers/sedFileWriteParser.js';
Expand Down Expand Up @@ -72,6 +73,18 @@ export class TreeSitterCommandParser extends Disposable {
return captures;
}

async extractCommandKeywords(languageId: TreeSitterCommandParserLanguage, commandLine: string): Promise<string[]> {
const captures = await this._queryTree(languageId, commandLine, '(command_name) @command');
const keywords = new Set<string>();
for (const capture of captures) {
const normalized = this._normalizeCommandKeyword(capture.node.text);
if (normalized) {
keywords.add(normalized);
}
}
return [...keywords];
}

async getFileWrites(languageId: TreeSitterCommandParserLanguage, commandLine: string): Promise<string[]> {
let query: string;
switch (languageId) {
Expand Down Expand Up @@ -124,6 +137,17 @@ export class TreeSitterCommandParser extends Disposable {
return query.captures(tree.rootNode);
}

private _normalizeCommandKeyword(token: string): string | undefined {
const unquoted = token.replace(/^['"]|['"]$/g, '');
if (!unquoted) {
return undefined;
}

const pathBase = unquoted.includes('\\') ? win32.basename(unquoted) : posix.basename(unquoted);
const normalized = pathBase.toLowerCase().replace(/\.(?:exe|cmd|bat|ps1)$/i, '');
return normalized || undefined;
}

private async _doQuery(languageId: TreeSitterCommandParserLanguage, commandLine: string, querySource: string): Promise<{ tree: Tree; query: Query }> {
const language = await this._treeSitterLibraryService.getLanguagePromise(languageId);
if (!language) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -564,6 +564,12 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary<IConfigurati
items: { type: 'string' },
default: []
},
allowRead: {
type: 'array',
description: localize('agentSandbox.linuxFileSystemSetting.allowRead', "Array of paths to re-allow read access within denied regions. Takes precedence over denyRead."),
items: { type: 'string' },
default: []
},
allowWrite: {
type: 'array',
description: localize('agentSandbox.linuxFileSystemSetting.allowWrite', "Array of paths to allow write access. Leave empty to disallow all writes."),
Expand All @@ -579,6 +585,7 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary<IConfigurati
},
default: {
denyRead: [],
allowRead: [],
allowWrite: ['.'],
denyWrite: []
},
Expand All @@ -595,6 +602,12 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary<IConfigurati
items: { type: 'string' },
default: []
},
allowRead: {
type: 'array',
description: localize('agentSandbox.macFileSystemSetting.allowRead', "Array of paths to re-allow read access within denied regions. Takes precedence over denyRead."),
items: { type: 'string' },
default: []
},
allowWrite: {
type: 'array',
description: localize('agentSandbox.macFileSystemSetting.allowWrite', "Array of paths to allow write access. Leave empty to disallow all writes."),
Expand All @@ -610,6 +623,7 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary<IConfigurati
},
default: {
denyRead: [],
allowRead: [],
allowWrite: ['.'],
denyWrite: []
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface ITerminalSandboxRuntimeConfig {
};
filesystem?: {
denyRead?: string[];
allowRead?: string[];
allowWrite?: string[];
denyWrite?: string[];
};
Expand Down
Loading
Loading