feat(core): implement windows sandbox expansion and denial detection (#24027)

This commit is contained in:
Tommaso Sciortino
2026-03-27 15:35:01 -07:00
committed by GitHub
parent 4034c030e7
commit 9e74a7ec18
5 changed files with 175 additions and 22 deletions

View File

@@ -34,6 +34,7 @@ import {
isStrictlyApproved,
} from './commandSafety.js';
import { verifySandboxOverrides } from '../utils/commandUtils.js';
import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -66,8 +67,8 @@ export class WindowsSandboxManager implements SandboxManager {
return isDangerousCommand(args);
}
parseDenials(_result: ShellExecutionResult): ParsedSandboxDenial | undefined {
return undefined; // TODO: Implement Windows-specific denial parsing
parseDenials(result: ShellExecutionResult): ParsedSandboxDenial | undefined {
return parseWindowsSandboxDenials(result);
}
/**

View File

@@ -0,0 +1,91 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { parseWindowsSandboxDenials } from './windowsSandboxDenialUtils.js';
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
describe('parseWindowsSandboxDenials', () => {
it('should detect CMD "Access is denied" and extract paths', () => {
const parsed = parseWindowsSandboxDenials({
output: 'Access is denied.\r\n',
error: new Error('Command failed: dir C:\\Windows\\System32\\config'),
} as unknown as ShellExecutionResult);
expect(parsed).toBeDefined();
expect(parsed?.filePaths).toContain('C:\\Windows\\System32\\config');
});
it('should detect PowerShell "Access to the path is denied"', () => {
const parsed = parseWindowsSandboxDenials({
output:
"Set-Content : Access to the path 'C:\\test.txt' is denied.\r\nAt line:1 char:1\r\n",
} as unknown as ShellExecutionResult);
expect(parsed).toBeDefined();
expect(parsed?.filePaths).toContain('C:\\test.txt');
});
it('should detect Node.js EPERM on Windows', () => {
const parsed = parseWindowsSandboxDenials({
error: {
message:
"Error: EPERM: operation not permitted, open 'D:\\project\\file.ts'",
},
} as unknown as ShellExecutionResult);
expect(parsed).toBeDefined();
expect(parsed?.filePaths).toContain('D:\\project\\file.ts');
});
it('should detect network denial (EACCES)', () => {
const parsed = parseWindowsSandboxDenials({
output: 'Error: listen EACCES: permission denied 0.0.0.0:3000',
} as unknown as ShellExecutionResult);
expect(parsed).toBeDefined();
expect(parsed?.network).toBe(true);
});
it('should detect native Windows error code 0x80070005', () => {
const parsed = parseWindowsSandboxDenials({
output: 'HRESULT: 0x80070005',
} as unknown as ShellExecutionResult);
expect(parsed).toBeDefined();
// No path in output, but recognized as denial
});
it('should handle extended-length paths', () => {
const parsed = parseWindowsSandboxDenials({
output: 'Access is denied to \\\\?\\C:\\Very\\Long\\Path\\file.txt',
} as unknown as ShellExecutionResult);
expect(parsed).toBeDefined();
expect(parsed?.filePaths).toContain(
'\\\\?\\C:\\Very\\Long\\Path\\file.txt',
);
});
it('should detect Windows paths with forward slashes', () => {
const parsed = parseWindowsSandboxDenials({
output:
"Error: EPERM: operation not permitted, open 'C:/project/file.ts'",
} as unknown as ShellExecutionResult);
expect(parsed).toBeDefined();
expect(parsed?.filePaths).toContain('C:/project/file.ts');
});
it('should return undefined if no denial detected', () => {
const parsed = parseWindowsSandboxDenials({
output:
'Directory of C:\\Users\r\n03/26/2026 11:40 AM <DIR> .',
} as unknown as ShellExecutionResult);
expect(parsed).toBeUndefined();
});
});

View File

@@ -0,0 +1,79 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { type ParsedSandboxDenial } from '../../services/sandboxManager.js';
import type { ShellExecutionResult } from '../../services/shellExecutionService.js';
/**
* Windows-specific sandbox denial detection.
* Extracts paths from "Access is denied" and related errors.
*/
export function parseWindowsSandboxDenials(
result: ShellExecutionResult,
): ParsedSandboxDenial | undefined {
const output = result.output || '';
const errorOutput = result.error?.message;
const combined = (output + ' ' + (errorOutput || '')).toLowerCase();
const isFileDenial = [
'access is denied',
'access to the path',
'unauthorizedaccessexception',
'0x80070005',
'eperm: operation not permitted',
].some((keyword) => combined.includes(keyword));
const isNetworkDenial = [
'eacces: permission denied',
'an attempt was made to access a socket in a way forbidden by its access permissions',
// 10013 is WSAEACCES
'10013',
].some((keyword) => combined.includes(keyword));
if (!isFileDenial && !isNetworkDenial) {
return undefined;
}
const filePaths = new Set<string>();
// Regex for Windows absolute paths (e.g., C:\Path or \\?\C:\Path)
// Handles drive letters and potentially quoted paths.
// We use two passes: one for quoted paths (which can contain spaces)
// and one for unquoted paths (which end at common separators).
// 1. Quoted paths: 'C:\Foo Bar' or "C:\Foo Bar"
const quotedRegex = /['"]((?:\\\\(?:\?|\.)\\)?[a-zA-Z]:[\\/][^'"]+)['"]/g;
for (const match of output.matchAll(quotedRegex)) {
filePaths.add(match[1]);
}
if (errorOutput) {
for (const match of errorOutput.matchAll(quotedRegex)) {
filePaths.add(match[1]);
}
}
// 2. Unquoted paths or paths in PowerShell error format: PermissionDenied: (C:\path:String)
const generalRegex =
/(?:^|[\s(])((?:\\\\(?:\?|\.)\\)?[a-zA-Z]:[\\/][^"'\s()<>|?*]+)/g;
for (const match of output.matchAll(generalRegex)) {
// Clean up trailing colon which might be part of the error message rather than the path
let p = match[1];
if (p.endsWith(':')) p = p.slice(0, -1);
filePaths.add(p);
}
if (errorOutput) {
for (const match of errorOutput.matchAll(generalRegex)) {
let p = match[1];
if (p.endsWith(':')) p = p.slice(0, -1);
filePaths.add(p);
}
}
return {
network: isNetworkDenial || undefined,
filePaths: filePaths.size > 0 ? Array.from(filePaths) : undefined,
};
}

View File

@@ -10,7 +10,6 @@ import fsPromises from 'node:fs/promises';
import { afterEach, describe, expect, it, vi, beforeEach } from 'vitest';
import {
NoopSandboxManager,
LocalSandboxManager,
sanitizePaths,
findSecretFiles,
isSecretFile,
@@ -374,6 +373,7 @@ describe('SandboxManager', () => {
it.each([
{ platform: 'linux', expected: LinuxSandboxManager },
{ platform: 'darwin', expected: MacOsSandboxManager },
{ platform: 'win32', expected: WindowsSandboxManager },
] as const)(
'should return $expected.name if sandboxing is enabled and platform is $platform',
({ platform, expected }) => {
@@ -385,23 +385,5 @@ describe('SandboxManager', () => {
expect(manager).toBeInstanceOf(expected);
},
);
it("should return WindowsSandboxManager if sandboxing is enabled with 'windows-native' command on win32", () => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
const manager = createSandboxManager(
{ enabled: true, command: 'windows-native' },
{ workspace: '/workspace' },
);
expect(manager).toBeInstanceOf(WindowsSandboxManager);
});
it('should return LocalSandboxManager on win32 if command is not windows-native', () => {
vi.spyOn(os, 'platform').mockReturnValue('win32');
const manager = createSandboxManager(
{ enabled: true, command: 'docker' as unknown as 'windows-native' },
{ workspace: '/workspace' },
);
expect(manager).toBeInstanceOf(LocalSandboxManager);
});
});
});

View File

@@ -33,7 +33,7 @@ export function createSandboxManager(
}
if (sandbox?.enabled) {
if (os.platform() === 'win32' && sandbox?.command === 'windows-native') {
if (os.platform() === 'win32') {
return new WindowsSandboxManager(options);
} else if (os.platform() === 'linux') {
return new LinuxSandboxManager(options);